@frontmcp/adapters 0.12.2 → 1.0.0-beta.10

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
@@ -20,6 +20,7 @@ import { convertJsonSchemaToZod } from "zod-from-json-schema";
20
20
 
21
21
  // libs/adapters/src/openapi/openapi.utils.ts
22
22
  import { validateBaseUrl } from "@frontmcp/utils";
23
+ import { PublicMcpError } from "@frontmcp/sdk";
23
24
  function coerceToString(value, paramName, location) {
24
25
  if (value === null || value === void 0) {
25
26
  return "";
@@ -76,8 +77,10 @@ function buildRequest(tool2, input, security, baseUrl) {
76
77
  case "header": {
77
78
  const headerValue = coerceToString(value, mapper.key, "header");
78
79
  if (/[\r\n\x00\f\v]/.test(headerValue)) {
79
- throw new Error(
80
- `Invalid header value for '${mapper.key}': contains control characters (possible header injection attack)`
80
+ throw new PublicMcpError(
81
+ `Invalid header value for '${mapper.key}': contains control characters (possible header injection attack)`,
82
+ "INVALID_HEADER_VALUE",
83
+ 400
81
84
  );
82
85
  }
83
86
  headers.set(mapper.key, headerValue);
@@ -860,6 +863,174 @@ function getZodSchemaFromJsonSchema(jsonSchema, toolName, logger) {
860
863
  }
861
864
  }
862
865
 
866
+ // libs/adapters/src/openapi/openapi-spec-poller.ts
867
+ import { sha256Hex } from "@frontmcp/utils";
868
+
869
+ // libs/adapters/src/openapi/openapi.errors.ts
870
+ import { InternalMcpError } from "@frontmcp/sdk";
871
+ var OpenAPIFetchError = class extends InternalMcpError {
872
+ constructor(url, status, statusText) {
873
+ super(`OpenAPI spec fetch failed from "${url}": ${status} ${statusText}`, "OPENAPI_FETCH_FAILED");
874
+ }
875
+ };
876
+
877
+ // libs/adapters/src/openapi/openapi-spec-poller.ts
878
+ var DEFAULT_RETRY = {
879
+ maxRetries: 3,
880
+ initialDelayMs: 1e3,
881
+ maxDelayMs: 1e4,
882
+ backoffMultiplier: 2
883
+ };
884
+ var OpenApiSpecPoller = class {
885
+ url;
886
+ intervalMs;
887
+ fetchTimeoutMs;
888
+ changeDetection;
889
+ retry;
890
+ unhealthyThreshold;
891
+ headers;
892
+ callbacks;
893
+ intervalTimer = null;
894
+ lastHash = null;
895
+ lastEtag = null;
896
+ lastModified = null;
897
+ consecutiveFailures = 0;
898
+ isPolling = false;
899
+ wasUnhealthy = false;
900
+ health = "unknown";
901
+ constructor(url, options = {}, callbacks = {}) {
902
+ this.url = url;
903
+ this.intervalMs = options.intervalMs ?? 6e4;
904
+ this.fetchTimeoutMs = options.fetchTimeoutMs ?? 1e4;
905
+ this.changeDetection = options.changeDetection ?? "auto";
906
+ this.retry = { ...DEFAULT_RETRY, ...options.retry };
907
+ this.unhealthyThreshold = options.unhealthyThreshold ?? 3;
908
+ this.headers = options.headers ?? {};
909
+ this.callbacks = callbacks;
910
+ }
911
+ /**
912
+ * Start polling for changes.
913
+ */
914
+ start() {
915
+ if (this.intervalTimer) return;
916
+ this.poll().catch(() => {
917
+ });
918
+ this.intervalTimer = setInterval(() => {
919
+ this.poll().catch(() => {
920
+ });
921
+ }, this.intervalMs);
922
+ }
923
+ /**
924
+ * Stop polling.
925
+ */
926
+ stop() {
927
+ if (this.intervalTimer) {
928
+ clearInterval(this.intervalTimer);
929
+ this.intervalTimer = null;
930
+ }
931
+ }
932
+ /**
933
+ * Get current stats.
934
+ */
935
+ getStats() {
936
+ return {
937
+ hash: this.lastHash,
938
+ consecutiveFailures: this.consecutiveFailures,
939
+ health: this.health,
940
+ isRunning: this.intervalTimer !== null
941
+ };
942
+ }
943
+ /**
944
+ * Perform a single poll cycle.
945
+ */
946
+ async poll() {
947
+ if (this.isPolling) return;
948
+ this.isPolling = true;
949
+ try {
950
+ await this.fetchWithRetry();
951
+ } finally {
952
+ this.isPolling = false;
953
+ }
954
+ }
955
+ async fetchWithRetry() {
956
+ const { maxRetries, initialDelayMs, maxDelayMs, backoffMultiplier } = this.retry;
957
+ for (let attempt = 0; attempt <= maxRetries; attempt++) {
958
+ try {
959
+ await this.doFetch();
960
+ if (this.consecutiveFailures > 0) {
961
+ this.consecutiveFailures = 0;
962
+ if (this.wasUnhealthy) {
963
+ this.wasUnhealthy = false;
964
+ this.health = "healthy";
965
+ this.callbacks.onRecovered?.();
966
+ }
967
+ }
968
+ this.health = "healthy";
969
+ return;
970
+ } catch (error) {
971
+ if (attempt === maxRetries) {
972
+ this.consecutiveFailures++;
973
+ this.callbacks.onError?.(error instanceof Error ? error : new Error(String(error)));
974
+ if (this.consecutiveFailures >= this.unhealthyThreshold && !this.wasUnhealthy) {
975
+ this.wasUnhealthy = true;
976
+ this.health = "unhealthy";
977
+ this.callbacks.onUnhealthy?.(this.consecutiveFailures);
978
+ }
979
+ return;
980
+ }
981
+ const delay = Math.min(initialDelayMs * Math.pow(backoffMultiplier, attempt), maxDelayMs);
982
+ await new Promise((r) => setTimeout(r, delay));
983
+ }
984
+ }
985
+ }
986
+ async doFetch() {
987
+ const headers = { ...this.headers };
988
+ if (this.changeDetection !== "content-hash") {
989
+ if (this.lastEtag) {
990
+ headers["If-None-Match"] = this.lastEtag;
991
+ }
992
+ if (this.lastModified) {
993
+ headers["If-Modified-Since"] = this.lastModified;
994
+ }
995
+ }
996
+ const controller = new AbortController();
997
+ const timeout = setTimeout(() => controller.abort(), this.fetchTimeoutMs);
998
+ try {
999
+ const response = await fetch(this.url, {
1000
+ headers,
1001
+ signal: controller.signal
1002
+ });
1003
+ if (response.status === 304) {
1004
+ this.callbacks.onUnchanged?.();
1005
+ return;
1006
+ }
1007
+ if (!response.ok) {
1008
+ throw new OpenAPIFetchError(this.url, response.status, response.statusText);
1009
+ }
1010
+ const etag = response.headers.get("etag");
1011
+ const lastModified = response.headers.get("last-modified");
1012
+ if (etag) this.lastEtag = etag;
1013
+ if (lastModified) this.lastModified = lastModified;
1014
+ const body = await response.text();
1015
+ const hash = sha256Hex(body);
1016
+ if (this.lastHash && this.lastHash === hash) {
1017
+ this.callbacks.onUnchanged?.();
1018
+ return;
1019
+ }
1020
+ this.lastHash = hash;
1021
+ this.callbacks.onChanged?.(body, hash);
1022
+ } finally {
1023
+ clearTimeout(timeout);
1024
+ }
1025
+ }
1026
+ /**
1027
+ * Dispose the poller and release resources.
1028
+ */
1029
+ dispose() {
1030
+ this.stop();
1031
+ }
1032
+ };
1033
+
863
1034
  // libs/adapters/src/openapi/openapi.adapter.ts
864
1035
  var RESERVED_KEYS2 = ["__proto__", "constructor", "prototype"];
865
1036
  function createConsoleLogger(prefix) {
@@ -877,10 +1048,18 @@ var OpenapiAdapter = class extends DynamicAdapter {
877
1048
  generator;
878
1049
  logger;
879
1050
  options;
1051
+ poller = null;
1052
+ updateCallbacks = /* @__PURE__ */ new Set();
1053
+ rebuildChain = Promise.resolve();
880
1054
  constructor(options) {
881
1055
  super();
882
1056
  this.options = options;
883
1057
  this.logger = options.logger ?? createConsoleLogger(`openapi:${options.name}`);
1058
+ if (options.polling?.enabled && !("url" in options)) {
1059
+ throw new Error(
1060
+ `[OpenAPI Adapter: ${options.name}] Polling requires URL-based options (use 'url' instead of 'spec').`
1061
+ );
1062
+ }
884
1063
  }
885
1064
  /**
886
1065
  * Receive the SDK logger. Called by the SDK before fetch().
@@ -956,13 +1135,31 @@ Add one of the following to your adapter configuration:
956
1135
  if (this.options.toolTransforms) {
957
1136
  transformedTools = transformedTools.map((tool2) => this.applyToolTransforms(tool2));
958
1137
  }
1138
+ const nameMap = /* @__PURE__ */ new Map();
1139
+ for (const tool2 of transformedTools) {
1140
+ const meta = tool2.metadata;
1141
+ const source = `${meta["method"]?.toUpperCase() ?? "?"} ${meta["path"] ?? "?"}`;
1142
+ const existing = nameMap.get(tool2.name);
1143
+ if (existing) {
1144
+ existing.push(source);
1145
+ } else {
1146
+ nameMap.set(tool2.name, [source]);
1147
+ }
1148
+ }
1149
+ for (const [name, sources] of nameMap) {
1150
+ if (sources.length > 1) {
1151
+ throw new Error(
1152
+ `Tool name collision: "${name}" produced by ${sources.length} operations: ${sources.join(", ")}. Rename conflicting operations in your OpenAPI spec or use toolTransforms.perTool to assign unique names.`
1153
+ );
1154
+ }
1155
+ }
959
1156
  if (this.options.inputTransforms) {
960
1157
  transformedTools = transformedTools.map((tool2) => this.applyInputTransforms(tool2));
961
1158
  }
962
1159
  if (this.options.schemaTransforms) {
963
1160
  transformedTools = transformedTools.map((tool2) => this.applySchemaTransforms(tool2));
964
1161
  }
965
- const dataTransforms = this.options.dataTransforms || this.options.outputTransforms;
1162
+ const dataTransforms = this.options.dataTransforms;
966
1163
  if (this.options.outputSchema || dataTransforms) {
967
1164
  transformedTools = await Promise.all(
968
1165
  transformedTools.map((tool2) => this.applyOutputSchemaOptions(tool2, dataTransforms))
@@ -1038,6 +1235,15 @@ ${opDescription}`;
1038
1235
  description
1039
1236
  };
1040
1237
  }
1238
+ /**
1239
+ * Look up a value in a perTool map by tool name, using own-property check.
1240
+ * @private
1241
+ */
1242
+ resolvePerTool(perTool, tool2) {
1243
+ if (!perTool) return void 0;
1244
+ if (Object.prototype.hasOwnProperty.call(perTool, tool2.name)) return perTool[tool2.name];
1245
+ return void 0;
1246
+ }
1041
1247
  /**
1042
1248
  * Collect tool transforms for a specific tool
1043
1249
  * @private
@@ -1058,8 +1264,9 @@ ${opDescription}`;
1058
1264
  result.examples = [...opts.global.examples];
1059
1265
  }
1060
1266
  }
1061
- if (opts.perTool?.[tool2.name]) {
1062
- const perTool = opts.perTool[tool2.name];
1267
+ const perToolMatch = this.resolvePerTool(opts.perTool, tool2);
1268
+ if (perToolMatch) {
1269
+ const perTool = perToolMatch;
1063
1270
  if (perTool.name) result.name = perTool.name;
1064
1271
  if (perTool.description) result.description = perTool.description;
1065
1272
  if (perTool.hideFromDiscovery !== void 0) result.hideFromDiscovery = perTool.hideFromDiscovery;
@@ -1136,8 +1343,9 @@ ${opDescription}`;
1136
1343
  if (opts.global) {
1137
1344
  transforms.push(...opts.global);
1138
1345
  }
1139
- if (opts.perTool?.[tool2.name]) {
1140
- transforms.push(...opts.perTool[tool2.name]);
1346
+ const perToolInputTransforms = this.resolvePerTool(opts.perTool, tool2);
1347
+ if (perToolInputTransforms) {
1348
+ transforms.push(...perToolInputTransforms);
1141
1349
  }
1142
1350
  if (opts.generator) {
1143
1351
  transforms.push(...opts.generator(tool2));
@@ -1284,8 +1492,9 @@ ${opDescription}`;
1284
1492
  const generated = opts.generator(tool2);
1285
1493
  if (generated) return generated;
1286
1494
  }
1287
- if (opts.perTool?.[tool2.name]) {
1288
- return opts.perTool[tool2.name];
1495
+ const perToolInputSchema = this.resolvePerTool(opts.perTool, tool2);
1496
+ if (perToolInputSchema) {
1497
+ return perToolInputSchema;
1289
1498
  }
1290
1499
  return opts.global;
1291
1500
  }
@@ -1300,8 +1509,9 @@ ${opDescription}`;
1300
1509
  const generated = opts.generator(tool2);
1301
1510
  if (generated) return generated;
1302
1511
  }
1303
- if (opts.perTool?.[tool2.name]) {
1304
- return opts.perTool[tool2.name];
1512
+ const perToolOutputSchema = this.resolvePerTool(opts.perTool, tool2);
1513
+ if (perToolOutputSchema) {
1514
+ return perToolOutputSchema;
1305
1515
  }
1306
1516
  return opts.global;
1307
1517
  }
@@ -1403,8 +1613,9 @@ ${this.formatSchemaAsSummary(schema)}`;
1403
1613
  if (opts.global) {
1404
1614
  result = { ...opts.global };
1405
1615
  }
1406
- if (opts.perTool?.[tool2.name]) {
1407
- result = { ...result, ...opts.perTool[tool2.name] };
1616
+ const perToolPre = this.resolvePerTool(opts.perTool, tool2);
1617
+ if (perToolPre) {
1618
+ result = { ...result, ...perToolPre };
1408
1619
  }
1409
1620
  if (opts.generator) {
1410
1621
  const generated = opts.generator(tool2);
@@ -1425,12 +1636,12 @@ ${this.formatSchemaAsSummary(schema)}`;
1425
1636
  if (opts.global) {
1426
1637
  result = { ...opts.global };
1427
1638
  }
1428
- if (opts.perTool?.[tool2.name]) {
1429
- const perTool = opts.perTool[tool2.name];
1639
+ const perToolPost = this.resolvePerTool(opts.perTool, tool2);
1640
+ if (perToolPost) {
1430
1641
  result = result ? {
1431
- transform: perTool.transform,
1432
- filter: perTool.filter ?? result.filter
1433
- } : perTool;
1642
+ transform: perToolPost.transform,
1643
+ filter: perToolPost.filter ?? result.filter
1644
+ } : perToolPost;
1434
1645
  }
1435
1646
  if (opts.generator) {
1436
1647
  const generated = opts.generator(tool2);
@@ -1482,6 +1693,66 @@ ${this.formatSchemaAsSummary(schema)}`;
1482
1693
  }
1483
1694
  return schema.type || "any";
1484
1695
  }
1696
+ // ============================================================================
1697
+ // Polling Lifecycle
1698
+ // ============================================================================
1699
+ /**
1700
+ * Register a callback for when the adapter's tools are updated via polling.
1701
+ * Returns an unsubscribe function.
1702
+ */
1703
+ onUpdate(callback) {
1704
+ this.updateCallbacks.add(callback);
1705
+ return () => {
1706
+ this.updateCallbacks.delete(callback);
1707
+ };
1708
+ }
1709
+ /**
1710
+ * Start polling for spec changes.
1711
+ * Creates the poller and on spec change, re-runs fetch() and calls the update callback.
1712
+ * Rebuilds are serialized via a Promise chain to prevent races.
1713
+ */
1714
+ startPolling() {
1715
+ if (this.poller) return;
1716
+ const polling = this.options.polling;
1717
+ if (!polling?.enabled || !("url" in this.options)) return;
1718
+ this.poller = new OpenApiSpecPoller(this.options.url, polling, {
1719
+ onChanged: (_spec, _hash) => {
1720
+ this.rebuildChain = this.rebuildChain.then(async () => {
1721
+ const previousGenerator = this.generator;
1722
+ try {
1723
+ this.generator = void 0;
1724
+ const response = await this.fetch();
1725
+ this.updateCallbacks.forEach((cb) => cb(response));
1726
+ this.logger.info("OpenAPI spec updated, tools rebuilt");
1727
+ } catch (error) {
1728
+ this.generator = previousGenerator;
1729
+ this.logger.error(`Failed to rebuild tools after spec change: ${error.message}`);
1730
+ }
1731
+ });
1732
+ },
1733
+ onError: (error) => {
1734
+ this.logger.warn(`OpenAPI spec poll error: ${error.message}`);
1735
+ },
1736
+ onUnhealthy: (failures) => {
1737
+ this.logger.error(`OpenAPI spec poller unhealthy after ${failures} consecutive failures`);
1738
+ },
1739
+ onRecovered: () => {
1740
+ this.logger.info("OpenAPI spec poller recovered");
1741
+ }
1742
+ });
1743
+ this.poller.start();
1744
+ this.logger.info(`Started polling OpenAPI spec at ${this.options.polling?.intervalMs ?? 6e4}ms intervals`);
1745
+ }
1746
+ /**
1747
+ * Stop polling for spec changes.
1748
+ */
1749
+ stopPolling() {
1750
+ if (this.poller) {
1751
+ this.poller.dispose();
1752
+ this.poller = null;
1753
+ this.logger.info("Stopped polling OpenAPI spec");
1754
+ }
1755
+ }
1485
1756
  };
1486
1757
  OpenapiAdapter = __decorateClass([
1487
1758
  Adapter({
@@ -1595,6 +1866,8 @@ function removeSecurityFromOperations(spec, operations) {
1595
1866
  }
1596
1867
  export {
1597
1868
  FRONTMCP_EXTENSION_KEY,
1869
+ OpenAPIFetchError,
1870
+ OpenApiSpecPoller,
1598
1871
  OpenapiAdapter,
1599
1872
  forceJwtSecurity,
1600
1873
  removeSecurityFromOperations