@frontmcp/adapters 1.8.1 → 1.8.3

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/esm/index.mjs CHANGED
@@ -183,13 +183,13 @@ var OpenApiSpecPoller = class {
183
183
  };
184
184
 
185
185
  // libs/adapters/src/openapi/openapi.security.ts
186
- import { SecurityResolver, createSecurityContext } from "mcp-from-openapi";
186
+ import { createSecurityContext, SecurityResolver } from "mcp-from-openapi";
187
187
  async function createSecurityContextFromAuth(tool2, ctx, options) {
188
188
  if (options.securityResolver) {
189
189
  return await options.securityResolver(tool2, ctx);
190
190
  }
191
191
  if (options.authProviderMapper) {
192
- const context = createSecurityContext({});
192
+ const context = {};
193
193
  const securitySchemes = /* @__PURE__ */ new Set();
194
194
  for (const mapper of tool2.mapper) {
195
195
  if (mapper.security?.scheme) {
@@ -242,22 +242,38 @@ async function createSecurityContextFromAuth(tool2, ctx, options) {
242
242
  }
243
243
  }
244
244
  }
245
+ if (hasStaticAuth(options)) {
246
+ return createSecurityContext({ ...options.staticAuth, ...context });
247
+ }
245
248
  const hasAnyAuth = context.jwt || context.apiKey || context.basic || context.oauth2Token;
246
- const authToken = ctx.authInfo?.token;
247
- if (!hasAnyAuth && authToken) {
248
- if (typeof authToken !== "string") {
249
- throw new Error(`authInfo.token must be a string, but got: ${typeof authToken}`);
249
+ if (!hasAnyAuth && options.passthroughCallerToken === true) {
250
+ const callerToken = getCallerToken(ctx);
251
+ if (callerToken) {
252
+ context.jwt = callerToken;
250
253
  }
251
- context.jwt = authToken;
252
254
  }
253
- return context;
255
+ return createSecurityContext(context);
254
256
  }
255
- if (options.staticAuth) {
257
+ if (hasStaticAuth(options)) {
256
258
  return createSecurityContext(options.staticAuth);
257
259
  }
258
- return createSecurityContext({
259
- jwt: ctx.authInfo?.token
260
- });
260
+ if (options.passthroughCallerToken === true) {
261
+ return createSecurityContext({ jwt: getCallerToken(ctx) });
262
+ }
263
+ return createSecurityContext({});
264
+ }
265
+ function hasStaticAuth(options) {
266
+ return !!options.staticAuth && Object.keys(options.staticAuth).length > 0;
267
+ }
268
+ function getCallerToken(ctx) {
269
+ const authToken = ctx.authInfo?.token;
270
+ if (authToken === void 0 || authToken === null || authToken === "") {
271
+ return void 0;
272
+ }
273
+ if (typeof authToken !== "string") {
274
+ throw new Error(`authInfo.token must be a string, but got: ${typeof authToken}`);
275
+ }
276
+ return authToken;
261
277
  }
262
278
  function extractSecuritySchemes(tools) {
263
279
  const schemes = /* @__PURE__ */ new Set();
@@ -293,12 +309,12 @@ function validateSecurityConfiguration(tools, options) {
293
309
  );
294
310
  return result;
295
311
  }
296
- if (options.staticAuth && Object.keys(options.staticAuth).length > 0) {
312
+ if (hasStaticAuth(options)) {
297
313
  result.securityRiskScore = "medium";
298
314
  result.warnings.push(
299
315
  "SECURITY INFO: Using staticAuth with hardcoded credentials. Ensure credentials are stored securely (environment variables, secrets manager)."
300
316
  );
301
- return result;
317
+ return withPassthroughRisk(result, securitySchemes, options);
302
318
  }
303
319
  const schemesInInput = new Set(options.securitySchemesInInput || []);
304
320
  if (options.authProviderMapper || schemesInInput.size > 0) {
@@ -308,13 +324,18 @@ function validateSecurityConfiguration(tools, options) {
308
324
  `INFO: Per-scheme security control enabled. Schemes in input: ${Array.from(schemesInInput).join(", ")}`
309
325
  );
310
326
  }
327
+ const passthroughFallback = [];
311
328
  for (const scheme of securitySchemes) {
312
329
  if (schemesInInput.has(scheme)) {
313
330
  continue;
314
331
  }
315
332
  if (!options.authProviderMapper?.[scheme]) {
316
- result.valid = false;
317
- result.missingMappings.push(scheme);
333
+ if (options.passthroughCallerToken === true) {
334
+ passthroughFallback.push(scheme);
335
+ } else {
336
+ result.valid = false;
337
+ result.missingMappings.push(scheme);
338
+ }
318
339
  }
319
340
  }
320
341
  if (!result.valid) {
@@ -322,19 +343,36 @@ function validateSecurityConfiguration(tools, options) {
322
343
  `ERROR: Missing auth provider mappings for security schemes: ${result.missingMappings.join(", ")}`
323
344
  );
324
345
  }
346
+ withPassthroughRisk(result, securitySchemes, options);
347
+ if (passthroughFallback.length > 0) {
348
+ result.warnings.push(
349
+ `SECURITY WARNING: Security schemes with no authProviderMapper entry (${passthroughFallback.join(", ")}) get the MCP client's own token, because passthroughCallerToken is enabled.`
350
+ );
351
+ }
325
352
  return result;
326
353
  }
327
- if (securitySchemes.size > 0) {
354
+ if (securitySchemes.size > 0 && options.passthroughCallerToken !== true) {
355
+ const schemesStr = Array.from(securitySchemes).join(", ");
328
356
  result.securityRiskScore = "medium";
329
357
  result.warnings.push(
330
- `INFO: No auth configuration provided. Using default ctx.authInfo.token for all security schemes: ${Array.from(
331
- securitySchemes
332
- ).join(", ")}`
333
- );
334
- result.warnings.push(
335
- "RECOMMENDATION: For multiple auth providers, use authProviderMapper or securityResolver to map each security scheme to the correct auth provider."
358
+ `SECURITY WARNING: No auth configuration provided, so the adapter has no credentials for the API (security schemes: ${schemesStr}). Operations that require authentication will fail. The MCP client's own token is not forwarded: configure authProviderMapper, securityResolver or staticAuth, or set passthroughCallerToken: true if the API is meant to receive the caller's MCP token.`
336
359
  );
337
360
  }
361
+ return withPassthroughRisk(result, securitySchemes, options);
362
+ }
363
+ function canPassThroughCallerToken(options) {
364
+ return options.passthroughCallerToken === true && !options.securityResolver && !hasStaticAuth(options);
365
+ }
366
+ function withPassthroughRisk(result, securitySchemes, options) {
367
+ if (securitySchemes.size === 0 || !canPassThroughCallerToken(options)) {
368
+ return result;
369
+ }
370
+ const schemesStr = Array.from(securitySchemes).join(", ");
371
+ const when = options.authProviderMapper ? " whenever no authProviderMapper function returns a credential" : "";
372
+ result.securityRiskScore = "high";
373
+ result.warnings.push(
374
+ `SECURITY WARNING: passthroughCallerToken is enabled. The MCP client's own token (ctx.authInfo.token) is sent to the API${when} for security schemes: ${schemesStr}. Only use this when the API accepts tokens issued for this MCP server.`
375
+ );
338
376
  return result;
339
377
  }
340
378
  async function resolveToolSecurity(tool2, ctx, options) {
@@ -352,9 +390,10 @@ async function resolveToolSecurity(tool2, ctx, options) {
352
390
  Required security schemes: ${schemesStr}
353
391
  Solutions:
354
392
  1. Add authProviderMapper: { '${firstScheme}': (ctx) => ctx.authInfo.user?.token }
355
- 2. Add securityResolver: (tool, ctx) => ({ jwt: ctx.authInfo.token })
393
+ 2. Add securityResolver: async (tool, ctx) => ({ jwt: await getApiToken(ctx) })
356
394
  3. Add staticAuth: { jwt: process.env.API_TOKEN }
357
- 4. Set generateOptions.includeSecurityInInput: true (not recommended for production)`
395
+ 4. Set passthroughCallerToken: true, only if the API accepts the MCP client's own token
396
+ 5. Set generateOptions.includeSecurityInInput: true (not recommended for production)`
358
397
  );
359
398
  }
360
399
  return await securityResolver.resolve(tool2.mapper, securityContext);
@@ -1155,7 +1194,7 @@ Add one of the following to your adapter configuration:
1155
1194
  }
1156
1195
 
1157
1196
  2. securityResolver:
1158
- securityResolver: (tool, authInfo) => ({ jwt: authInfo.token })
1197
+ securityResolver: async (tool, ctx) => ({ jwt: await getApiToken(ctx) })
1159
1198
 
1160
1199
  3. staticAuth:
1161
1200
  staticAuth: { jwt: process.env.API_TOKEN }
@@ -183,13 +183,13 @@ var OpenApiSpecPoller = class {
183
183
  };
184
184
 
185
185
  // libs/adapters/src/openapi/openapi.security.ts
186
- import { SecurityResolver, createSecurityContext } from "mcp-from-openapi";
186
+ import { createSecurityContext, SecurityResolver } from "mcp-from-openapi";
187
187
  async function createSecurityContextFromAuth(tool2, ctx, options) {
188
188
  if (options.securityResolver) {
189
189
  return await options.securityResolver(tool2, ctx);
190
190
  }
191
191
  if (options.authProviderMapper) {
192
- const context = createSecurityContext({});
192
+ const context = {};
193
193
  const securitySchemes = /* @__PURE__ */ new Set();
194
194
  for (const mapper of tool2.mapper) {
195
195
  if (mapper.security?.scheme) {
@@ -242,22 +242,38 @@ async function createSecurityContextFromAuth(tool2, ctx, options) {
242
242
  }
243
243
  }
244
244
  }
245
+ if (hasStaticAuth(options)) {
246
+ return createSecurityContext({ ...options.staticAuth, ...context });
247
+ }
245
248
  const hasAnyAuth = context.jwt || context.apiKey || context.basic || context.oauth2Token;
246
- const authToken = ctx.authInfo?.token;
247
- if (!hasAnyAuth && authToken) {
248
- if (typeof authToken !== "string") {
249
- throw new Error(`authInfo.token must be a string, but got: ${typeof authToken}`);
249
+ if (!hasAnyAuth && options.passthroughCallerToken === true) {
250
+ const callerToken = getCallerToken(ctx);
251
+ if (callerToken) {
252
+ context.jwt = callerToken;
250
253
  }
251
- context.jwt = authToken;
252
254
  }
253
- return context;
255
+ return createSecurityContext(context);
254
256
  }
255
- if (options.staticAuth) {
257
+ if (hasStaticAuth(options)) {
256
258
  return createSecurityContext(options.staticAuth);
257
259
  }
258
- return createSecurityContext({
259
- jwt: ctx.authInfo?.token
260
- });
260
+ if (options.passthroughCallerToken === true) {
261
+ return createSecurityContext({ jwt: getCallerToken(ctx) });
262
+ }
263
+ return createSecurityContext({});
264
+ }
265
+ function hasStaticAuth(options) {
266
+ return !!options.staticAuth && Object.keys(options.staticAuth).length > 0;
267
+ }
268
+ function getCallerToken(ctx) {
269
+ const authToken = ctx.authInfo?.token;
270
+ if (authToken === void 0 || authToken === null || authToken === "") {
271
+ return void 0;
272
+ }
273
+ if (typeof authToken !== "string") {
274
+ throw new Error(`authInfo.token must be a string, but got: ${typeof authToken}`);
275
+ }
276
+ return authToken;
261
277
  }
262
278
  function extractSecuritySchemes(tools) {
263
279
  const schemes = /* @__PURE__ */ new Set();
@@ -293,12 +309,12 @@ function validateSecurityConfiguration(tools, options) {
293
309
  );
294
310
  return result;
295
311
  }
296
- if (options.staticAuth && Object.keys(options.staticAuth).length > 0) {
312
+ if (hasStaticAuth(options)) {
297
313
  result.securityRiskScore = "medium";
298
314
  result.warnings.push(
299
315
  "SECURITY INFO: Using staticAuth with hardcoded credentials. Ensure credentials are stored securely (environment variables, secrets manager)."
300
316
  );
301
- return result;
317
+ return withPassthroughRisk(result, securitySchemes, options);
302
318
  }
303
319
  const schemesInInput = new Set(options.securitySchemesInInput || []);
304
320
  if (options.authProviderMapper || schemesInInput.size > 0) {
@@ -308,13 +324,18 @@ function validateSecurityConfiguration(tools, options) {
308
324
  `INFO: Per-scheme security control enabled. Schemes in input: ${Array.from(schemesInInput).join(", ")}`
309
325
  );
310
326
  }
327
+ const passthroughFallback = [];
311
328
  for (const scheme of securitySchemes) {
312
329
  if (schemesInInput.has(scheme)) {
313
330
  continue;
314
331
  }
315
332
  if (!options.authProviderMapper?.[scheme]) {
316
- result.valid = false;
317
- result.missingMappings.push(scheme);
333
+ if (options.passthroughCallerToken === true) {
334
+ passthroughFallback.push(scheme);
335
+ } else {
336
+ result.valid = false;
337
+ result.missingMappings.push(scheme);
338
+ }
318
339
  }
319
340
  }
320
341
  if (!result.valid) {
@@ -322,19 +343,36 @@ function validateSecurityConfiguration(tools, options) {
322
343
  `ERROR: Missing auth provider mappings for security schemes: ${result.missingMappings.join(", ")}`
323
344
  );
324
345
  }
346
+ withPassthroughRisk(result, securitySchemes, options);
347
+ if (passthroughFallback.length > 0) {
348
+ result.warnings.push(
349
+ `SECURITY WARNING: Security schemes with no authProviderMapper entry (${passthroughFallback.join(", ")}) get the MCP client's own token, because passthroughCallerToken is enabled.`
350
+ );
351
+ }
325
352
  return result;
326
353
  }
327
- if (securitySchemes.size > 0) {
354
+ if (securitySchemes.size > 0 && options.passthroughCallerToken !== true) {
355
+ const schemesStr = Array.from(securitySchemes).join(", ");
328
356
  result.securityRiskScore = "medium";
329
357
  result.warnings.push(
330
- `INFO: No auth configuration provided. Using default ctx.authInfo.token for all security schemes: ${Array.from(
331
- securitySchemes
332
- ).join(", ")}`
333
- );
334
- result.warnings.push(
335
- "RECOMMENDATION: For multiple auth providers, use authProviderMapper or securityResolver to map each security scheme to the correct auth provider."
358
+ `SECURITY WARNING: No auth configuration provided, so the adapter has no credentials for the API (security schemes: ${schemesStr}). Operations that require authentication will fail. The MCP client's own token is not forwarded: configure authProviderMapper, securityResolver or staticAuth, or set passthroughCallerToken: true if the API is meant to receive the caller's MCP token.`
336
359
  );
337
360
  }
361
+ return withPassthroughRisk(result, securitySchemes, options);
362
+ }
363
+ function canPassThroughCallerToken(options) {
364
+ return options.passthroughCallerToken === true && !options.securityResolver && !hasStaticAuth(options);
365
+ }
366
+ function withPassthroughRisk(result, securitySchemes, options) {
367
+ if (securitySchemes.size === 0 || !canPassThroughCallerToken(options)) {
368
+ return result;
369
+ }
370
+ const schemesStr = Array.from(securitySchemes).join(", ");
371
+ const when = options.authProviderMapper ? " whenever no authProviderMapper function returns a credential" : "";
372
+ result.securityRiskScore = "high";
373
+ result.warnings.push(
374
+ `SECURITY WARNING: passthroughCallerToken is enabled. The MCP client's own token (ctx.authInfo.token) is sent to the API${when} for security schemes: ${schemesStr}. Only use this when the API accepts tokens issued for this MCP server.`
375
+ );
338
376
  return result;
339
377
  }
340
378
  async function resolveToolSecurity(tool2, ctx, options) {
@@ -352,9 +390,10 @@ async function resolveToolSecurity(tool2, ctx, options) {
352
390
  Required security schemes: ${schemesStr}
353
391
  Solutions:
354
392
  1. Add authProviderMapper: { '${firstScheme}': (ctx) => ctx.authInfo.user?.token }
355
- 2. Add securityResolver: (tool, ctx) => ({ jwt: ctx.authInfo.token })
393
+ 2. Add securityResolver: async (tool, ctx) => ({ jwt: await getApiToken(ctx) })
356
394
  3. Add staticAuth: { jwt: process.env.API_TOKEN }
357
- 4. Set generateOptions.includeSecurityInInput: true (not recommended for production)`
395
+ 4. Set passthroughCallerToken: true, only if the API accepts the MCP client's own token
396
+ 5. Set generateOptions.includeSecurityInInput: true (not recommended for production)`
358
397
  );
359
398
  }
360
399
  return await securityResolver.resolve(tool2.mapper, securityContext);
@@ -1155,7 +1194,7 @@ Add one of the following to your adapter configuration:
1155
1194
  }
1156
1195
 
1157
1196
  2. securityResolver:
1158
- securityResolver: (tool, authInfo) => ({ jwt: authInfo.token })
1197
+ securityResolver: async (tool, ctx) => ({ jwt: await getApiToken(ctx) })
1159
1198
 
1160
1199
  3. staticAuth:
1161
1200
  staticAuth: { jwt: process.env.API_TOKEN }
package/esm/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frontmcp/adapters",
3
- "version": "1.8.1",
3
+ "version": "1.8.3",
4
4
  "description": "Adapters for the FrontMCP framework",
5
5
  "author": "AgentFront <info@agentfront.dev>",
6
6
  "homepage": "https://docs.agentfront.dev",
@@ -64,15 +64,15 @@
64
64
  "node": ">=24.0.0"
65
65
  },
66
66
  "dependencies": {
67
- "@frontmcp/auth": "1.8.1",
68
- "@frontmcp/di": "1.8.1",
69
- "@frontmcp/sdk": "1.8.1",
70
- "@frontmcp/utils": "1.8.1",
67
+ "@frontmcp/auth": "1.8.3",
68
+ "@frontmcp/di": "1.8.3",
69
+ "@frontmcp/sdk": "1.8.3",
70
+ "@frontmcp/utils": "1.8.3",
71
71
  "js-yaml": "^4.1.0",
72
72
  "mcp-from-openapi": "2.8.0",
73
73
  "openapi-types": "^12.1.3"
74
74
  },
75
75
  "peerDependencies": {
76
- "@frontmcp/lazy-zod": "1.8.1"
76
+ "@frontmcp/lazy-zod": "1.8.3"
77
77
  }
78
78
  }
@@ -611,7 +611,13 @@ var npmSourceSchema = z2.object({
611
611
  exportName: z2.string().optional(),
612
612
  /**
613
613
  * Verify GitHub artifact attestation / npm provenance for the package before loading.
614
- * Defaults to true. Set false (with warning) only for local development packages.
614
+ * Defaults to true. Provenance verification is not implemented yet, so `true` refuses
615
+ * to load the package; set `false` to load it without a provenance check.
616
+ *
617
+ * With `false`, the package is imported (its code runs) before the bundle is parsed, so
618
+ * bundle signing (`requireSignature` + `trustedKeys`) cannot protect the import itself.
619
+ * Only use it for a package you trust: pin its exact version in `package.json` and
620
+ * install from a lockfile with integrity hashes.
615
621
  */
616
622
  verifyProvenance: z2.boolean().default(true)
617
623
  });
@@ -619,11 +625,15 @@ var saasSourceSchema = z2.object({
619
625
  type: z2.literal("saas"),
620
626
  /** SaaS pull endpoint, e.g. `https://cloud.frontmcp.dev/v1/bundles/<bundleId>`. */
621
627
  endpoint: httpsUrl,
622
- /** Pinned JWT issued by the SaaS for the customer's FrontMCP server. */
628
+ /**
629
+ * Pinned JWT issued by the SaaS for the customer's FrontMCP server. Verified
630
+ * before every pull: signed by a key in `jwksUrl`, `iss` = `expectedIssuer`,
631
+ * `aud` includes `expectedAudience`, not expired. A rejected token stops the pull.
632
+ */
623
633
  authToken: z2.string().min(1),
624
634
  /**
625
- * RFC 8707 resource indicator the JWT must encode. The plugin verifies
626
- * `resource` AND `aud` claims match this on every push/pull.
635
+ * RFC 8707 resource indicator the JWT must encode: its `aud` claim must include
636
+ * this value (and its `resource` claim too, when it has one).
627
637
  */
628
638
  expectedAudience: z2.string().min(1),
629
639
  /** Polling interval in ms (boot pull is always immediate). Default 300000 (5 min). */
@@ -633,9 +643,9 @@ var saasSourceSchema = z2.object({
633
643
  * updates synchronously. Default false in v1.2 (interval polling only).
634
644
  */
635
645
  enableWebhook: z2.boolean().default(false),
636
- /** JWKS URL for verifying SaaS-issued tokens. */
646
+ /** JWKS URL for verifying SaaS-issued tokens (fetched without credentials, redirects refused). */
637
647
  jwksUrl: httpsUrl,
638
- /** Expected issuer (`iss`) claim. */
648
+ /** Expected issuer (`iss`) claim of `authToken`. */
639
649
  expectedIssuer: z2.string().min(1)
640
650
  });
641
651
  var inlineSourceSchema = z2.object({
@@ -733,8 +743,8 @@ var NpmSource = class {
733
743
  }
734
744
  async loadBundleFromPackage() {
735
745
  if (this.options.verifyProvenance) {
736
- this.logger.warn(
737
- `[npm-source] verifyProvenance=true requested but provenance verification is not yet implemented in OSS (planned v1.2.x). Loading "${this.options.packageName}" without provenance check.`
746
+ throw new Error(
747
+ `npm source "${this.options.packageName}": verifyProvenance is true, but npm provenance verification is not implemented, so the package was not loaded. Set verifyProvenance: false to load it without a provenance check, only for a package you trust: importing it runs its code before any bundle signature is checked, so pin its exact version and install it from a lockfile with integrity hashes.`
738
748
  );
739
749
  }
740
750
  const mod = await this.dynamicImport(this.options.packageName);
@@ -769,9 +779,33 @@ var NpmSource = class {
769
779
  };
770
780
 
771
781
  // libs/adapters/src/skills/sources/saas-pull.source.ts
782
+ import { JwksService } from "@frontmcp/auth";
772
783
  import { dirname, ensureDir, isRedirectResponse, pathResolve, readFile, writeFile } from "@frontmcp/utils";
773
784
  var DEFAULT_CACHE_DIR = ".frontmcp/skilled-openapi";
774
785
  var DEFAULT_PULL_TIMEOUT_MS = 3e4;
786
+ var USER_AGENT = "frontmcp-skilled-openapi";
787
+ var SaasPullTokenRejectedError = class extends Error {
788
+ constructor(reason) {
789
+ super(`[saas-source] pull token rejected: ${reason}`);
790
+ this.name = "SaasPullTokenRejectedError";
791
+ }
792
+ };
793
+ function claimValues(claim) {
794
+ if (Array.isArray(claim)) return claim;
795
+ return claim === void 0 ? [] : [claim];
796
+ }
797
+ var PUBLIC_KEY_MEMBERS = /* @__PURE__ */ new Map([
798
+ ["RSA", ["n", "e"]],
799
+ ["EC", ["crv", "x", "y"]],
800
+ ["OKP", ["crv", "x"]]
801
+ ]);
802
+ function isUsableSigningKey(key) {
803
+ if (typeof key !== "object" || key === null) return false;
804
+ const jwk = key;
805
+ const members = typeof jwk["kty"] === "string" ? PUBLIC_KEY_MEMBERS.get(jwk["kty"]) : void 0;
806
+ if (!members || jwk["use"] !== void 0 && jwk["use"] !== "sig") return false;
807
+ return members.every((m) => typeof jwk[m] === "string" && jwk[m] !== "");
808
+ }
775
809
  var SaasPullSource = class {
776
810
  constructor(options, cacheDir, logger, deps = {}) {
777
811
  this.options = options;
@@ -789,6 +823,7 @@ var SaasPullSource = class {
789
823
  pollHandle;
790
824
  inFlight = false;
791
825
  stopped = false;
826
+ jwksService = new JwksService({});
792
827
  async start() {
793
828
  if (this.stopped || this.inFlight) {
794
829
  this.schedulePoll();
@@ -801,6 +836,7 @@ var SaasPullSource = class {
801
836
  bundle = await this.fetchOnce();
802
837
  await this.persistCache(bundle);
803
838
  } catch (e) {
839
+ if (e instanceof SaasPullTokenRejectedError) throw e;
804
840
  this.logger.warn(
805
841
  `[saas-source] initial pull failed (${e.message}); attempting cached bundle fallback`
806
842
  );
@@ -872,7 +908,11 @@ var SaasPullSource = class {
872
908
  await this.persistCache(bundle);
873
909
  this.notify(bundle);
874
910
  } catch (e) {
875
- this.logger.warn(`[saas-source] poll failed: ${e.message}`);
911
+ if (e instanceof SaasPullTokenRejectedError) {
912
+ this.logger.error(`[saas-source] poll refused: ${e.message}; keeping the current bundle`);
913
+ } else {
914
+ this.logger.warn(`[saas-source] poll failed: ${e.message}`);
915
+ }
876
916
  } finally {
877
917
  this.inFlight = false;
878
918
  this.schedulePoll();
@@ -893,11 +933,53 @@ var SaasPullSource = class {
893
933
  clearTimeout(timer);
894
934
  }
895
935
  }
936
+ /**
937
+ * Verify the pinned pull token against the issuer's JWKS, issuer and audience.
938
+ * Throws {@link SaasPullTokenRejectedError} when the token is refused, and a
939
+ * plain Error when the JWKS can't be fetched (an outage).
940
+ */
941
+ async verifyPullToken() {
942
+ const { jwksUrl, expectedIssuer, expectedAudience, authToken } = this.options;
943
+ const { status, body } = await this.httpGet(jwksUrl, { Accept: "application/json", "User-Agent": USER_AGENT });
944
+ if (status < 200 || status >= 300) {
945
+ throw new Error(`JWKS ${jwksUrl} responded ${status}`);
946
+ }
947
+ let jwks;
948
+ try {
949
+ jwks = JSON.parse(body);
950
+ } catch {
951
+ throw new Error(`JWKS ${jwksUrl} is not valid JSON`);
952
+ }
953
+ if (!jwks || !Array.isArray(jwks.keys) || jwks.keys.length === 0) {
954
+ throw new Error(`JWKS ${jwksUrl} has no keys`);
955
+ }
956
+ const signingKeys = jwks.keys.filter(isUsableSigningKey);
957
+ if (signingKeys.length === 0) {
958
+ throw new Error(`JWKS ${jwksUrl} has no usable signing keys`);
959
+ }
960
+ const verified = await this.jwksService.verifyTransparentToken(authToken, [
961
+ { id: "skilled-openapi-saas", issuerUrl: expectedIssuer, jwks: { keys: signingKeys } }
962
+ ]);
963
+ if (!verified.ok || !verified.payload) {
964
+ throw new SaasPullTokenRejectedError(
965
+ `not a valid token from issuer "${expectedIssuer}" signed by a key in ${jwksUrl} (${verified.error ?? "verification failed"})`
966
+ );
967
+ }
968
+ const payload = verified.payload;
969
+ if (!claimValues(payload["aud"]).includes(expectedAudience)) {
970
+ throw new SaasPullTokenRejectedError(`its "aud" claim does not include "${expectedAudience}"`);
971
+ }
972
+ const resource = claimValues(payload["resource"]);
973
+ if (resource.length > 0 && !resource.includes(expectedAudience)) {
974
+ throw new SaasPullTokenRejectedError(`its "resource" claim does not include "${expectedAudience}"`);
975
+ }
976
+ }
896
977
  async fetchOnce() {
978
+ await this.verifyPullToken();
897
979
  const { status, body } = await this.httpGet(this.options.endpoint, {
898
980
  Authorization: `Bearer ${this.options.authToken}`,
899
981
  Accept: "application/json, application/yaml",
900
- "User-Agent": "frontmcp-skilled-openapi"
982
+ "User-Agent": USER_AGENT
901
983
  });
902
984
  if (status < 200 || status >= 300) {
903
985
  throw new Error(`pull responded ${status}`);
@@ -1521,13 +1603,13 @@ function verifyBundleSignature(bundle, trustedKeys, telemetry) {
1521
1603
  }
1522
1604
 
1523
1605
  // libs/adapters/src/skills/security/jwt-verifier.ts
1524
- import { JwksService } from "@frontmcp/auth";
1606
+ import { JwksService as JwksService2 } from "@frontmcp/auth";
1525
1607
  var DEFAULT_REQUIRED_ROLES = ["frontmcp:cloud:push"];
1526
1608
  var BundlePushJwtVerifier = class {
1527
1609
  constructor(options, logger, jwks) {
1528
1610
  this.options = options;
1529
1611
  this.logger = logger;
1530
- this.jwks = jwks ?? new JwksService({});
1612
+ this.jwks = jwks ?? new JwksService2({});
1531
1613
  this.providerRef = {
1532
1614
  id: "skilled-openapi-cloud-push",
1533
1615
  issuerUrl: options.expectedIssuer,