@tinycloud/mcp 0.8.3-beta.7 → 0.8.3-beta.8

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/dist/index.cjs CHANGED
@@ -522,7 +522,7 @@ function outcomeCode(result) {
522
522
  // package.json
523
523
  var package_default = {
524
524
  name: "@tinycloud/mcp",
525
- version: "0.8.3-beta.7",
525
+ version: "0.8.3-beta.8",
526
526
  description: "TinyCloud delegated operations MCP server",
527
527
  keywords: [
528
528
  "tinycloud",
@@ -752,7 +752,9 @@ function createOpenKeyTokenVerifier(config) {
752
752
  issuer: config.issuer,
753
753
  audience: config.audience
754
754
  });
755
- return authInfo(token, payload, config);
755
+ const info = authInfo(token, payload, config);
756
+ if (config.tokenStatus !== void 0) await requireUsableStatus(config.tokenStatus, token);
757
+ return info;
756
758
  } catch (error) {
757
759
  if (error instanceof import_jose.errors.JOSEError) {
758
760
  throw new import_server4.OAuthError(import_server4.OAuthErrorCode.InvalidToken, "The OpenKey access token is invalid.");
@@ -762,6 +764,16 @@ function createOpenKeyTokenVerifier(config) {
762
764
  }
763
765
  };
764
766
  }
767
+ async function requireUsableStatus(checker, token) {
768
+ const status = await checker.tokenStatus(token);
769
+ if (status === "active") return;
770
+ if (status === "inactive") {
771
+ throw new import_server4.OAuthError(import_server4.OAuthErrorCode.InvalidToken, "The OpenKey access token is no longer active.");
772
+ }
773
+ if (!checker.toleratesUnknown(token)) {
774
+ throw new import_server4.OAuthError(import_server4.OAuthErrorCode.ServerError, "The OpenKey access token status is unavailable.");
775
+ }
776
+ }
765
777
  function authInfo(token, payload, config) {
766
778
  if (typeof payload.sub !== "string" || payload.sub.length === 0) {
767
779
  throw new import_server4.OAuthError(import_server4.OAuthErrorCode.InvalidToken, "The OpenKey access token does not identify a subject.");
@@ -815,8 +827,152 @@ function firstString(...values) {
815
827
  return values.find((value) => typeof value === "string" && value.length > 0);
816
828
  }
817
829
 
818
- // src/remote-store.ts
830
+ // src/token-status.ts
819
831
  var import_node_crypto = require("crypto");
832
+ var import_jose2 = require("jose");
833
+ var ACTIVE_CACHE_MS = 3e4;
834
+ var DEFAULT_TOKEN_LIFETIME_SECONDS = 300;
835
+ var DEFAULT_TIMEOUT_MS = 3e3;
836
+ var MAX_CACHED_TOKENS = 1e4;
837
+ function createTokenStatusChecker(config) {
838
+ const doFetch = config.fetch ?? fetch;
839
+ const clock = config.now ?? Date.now;
840
+ const log = config.log ?? ((message) => void process.stderr.write(`[tinycloud-mcp-http] ${message}
841
+ `));
842
+ const timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
843
+ const inactive = /* @__PURE__ */ new Map();
844
+ const active = /* @__PURE__ */ new Map();
845
+ const inFlight = /* @__PURE__ */ new Map();
846
+ const confirmedActive = /* @__PURE__ */ new Map();
847
+ let credentialsRefused = false;
848
+ async function introspect(token) {
849
+ const headers = {
850
+ accept: "application/json",
851
+ "content-type": "application/x-www-form-urlencoded"
852
+ };
853
+ const body = new URLSearchParams({ token, token_type_hint: "access_token" });
854
+ if (config.authMethod === "basic") {
855
+ const encode = (value) => encodeURIComponent(value).replace(/%20/g, "+");
856
+ const credentials = `${encode(config.clientId)}:${encode(config.clientSecret)}`;
857
+ headers.authorization = `Basic ${Buffer.from(credentials).toString("base64")}`;
858
+ } else {
859
+ body.set("client_id", config.clientId);
860
+ body.set("client_secret", config.clientSecret);
861
+ }
862
+ try {
863
+ const response = await doFetch(config.endpoint, {
864
+ method: "POST",
865
+ headers,
866
+ body,
867
+ redirect: "error",
868
+ signal: AbortSignal.timeout(timeoutMs)
869
+ });
870
+ if (response.status === 401) return "credentials";
871
+ if (response.status !== 200) return "unknown";
872
+ const result = await response.json();
873
+ if (typeof result !== "object" || result === null || !("active" in result)) return "unknown";
874
+ if (result.active === false) return "inactive";
875
+ if (result.active !== true) return "unknown";
876
+ return answerMatchesToken(Object.fromEntries(Object.entries(result)), token, config.resourceUrl) ? "active" : "mismatch";
877
+ } catch {
878
+ return "unknown";
879
+ }
880
+ }
881
+ async function query(key, token) {
882
+ const outcome = await introspect(token);
883
+ if (outcome === "credentials") {
884
+ if (!credentialsRefused) {
885
+ log("OpenKey token introspection refused the MCP client credentials (401); token checks fail closed until it accepts them.");
886
+ }
887
+ credentialsRefused = true;
888
+ return "unknown";
889
+ }
890
+ if (outcome === "unknown") return outcome;
891
+ const now = clock();
892
+ prune(now);
893
+ if (outcome === "mismatch") {
894
+ inactive.set(key, tokenExpiryMs(token, now));
895
+ confirmedActive.delete(key);
896
+ return "inactive";
897
+ }
898
+ credentialsRefused = false;
899
+ if (outcome === "inactive") {
900
+ inactive.set(key, tokenExpiryMs(token, now));
901
+ confirmedActive.delete(key);
902
+ } else {
903
+ active.set(key, now + ACTIVE_CACHE_MS);
904
+ confirmedActive.delete(key);
905
+ confirmedActive.set(key, { at: now, expiresAt: tokenExpiryMs(token, now) });
906
+ }
907
+ return outcome;
908
+ }
909
+ function prune(now) {
910
+ for (const [key, until] of active) if (until <= now) active.delete(key);
911
+ for (const [key, until] of inactive) if (until <= now) inactive.delete(key);
912
+ for (const [key, entry] of confirmedActive) if (entry.expiresAt <= now) confirmedActive.delete(key);
913
+ for (const cache of [active, inactive, confirmedActive]) {
914
+ while (cache.size >= MAX_CACHED_TOKENS) {
915
+ const oldest = cache.keys().next().value;
916
+ if (oldest === void 0) break;
917
+ cache.delete(oldest);
918
+ }
919
+ }
920
+ }
921
+ return {
922
+ async tokenStatus(token) {
923
+ const key = (0, import_node_crypto.createHash)("sha256").update(token).digest("hex");
924
+ const now = clock();
925
+ const inactiveUntil = inactive.get(key);
926
+ if (inactiveUntil !== void 0 && inactiveUntil > now) return "inactive";
927
+ const activeUntil = active.get(key);
928
+ if (activeUntil !== void 0 && activeUntil > now) return "active";
929
+ const pending = inFlight.get(key);
930
+ if (pending !== void 0) return pending;
931
+ const asking = query(key, token).finally(() => inFlight.delete(key));
932
+ inFlight.set(key, asking);
933
+ return asking;
934
+ },
935
+ toleratesUnknown(token) {
936
+ if (credentialsRefused) return false;
937
+ const confirmed = confirmedActive.get((0, import_node_crypto.createHash)("sha256").update(token).digest("hex"));
938
+ if (confirmed === void 0) return false;
939
+ const now = clock();
940
+ return confirmed.expiresAt > now && now - confirmed.at < tokenLifetimeSeconds(token) * 1e3;
941
+ }
942
+ };
943
+ }
944
+ function answerMatchesToken(answer, token, resourceUrl) {
945
+ const jwt = claims(token);
946
+ if (typeof jwt.sub === "string" && answer.sub !== jwt.sub) return false;
947
+ const jwtClient = [jwt.azp, jwt.client_id].find((value) => typeof value === "string" && value.length > 0);
948
+ if (jwtClient !== void 0 && answer.client_id !== jwtClient) return false;
949
+ if (jwt.aud === void 0) return true;
950
+ const answered = audienceList(answer.aud);
951
+ const signed = audienceList(jwt.aud);
952
+ return answered.length > 0 && answered.includes(resourceUrl) && answered.every((entry) => signed.includes(entry));
953
+ }
954
+ function audienceList(value) {
955
+ const list = typeof value === "string" ? [value] : value;
956
+ return Array.isArray(list) && list.every((entry) => typeof entry === "string") ? list : [];
957
+ }
958
+ function tokenExpiryMs(token, now) {
959
+ const exp = claims(token).exp;
960
+ return typeof exp === "number" ? exp * 1e3 : now + DEFAULT_TOKEN_LIFETIME_SECONDS * 1e3;
961
+ }
962
+ function tokenLifetimeSeconds(token) {
963
+ const { exp, iat } = claims(token);
964
+ return typeof exp === "number" && typeof iat === "number" && exp > iat ? exp - iat : DEFAULT_TOKEN_LIFETIME_SECONDS;
965
+ }
966
+ function claims(token) {
967
+ try {
968
+ return (0, import_jose2.decodeJwt)(token);
969
+ } catch {
970
+ return {};
971
+ }
972
+ }
973
+
974
+ // src/remote-store.ts
975
+ var import_node_crypto2 = require("crypto");
820
976
  var import_promises = require("fs/promises");
821
977
  var import_node_path3 = require("path");
822
978
  var import_node_sdk2 = require("@tinycloud/node-sdk");
@@ -1408,7 +1564,7 @@ var RemoteTenantStore = class {
1408
1564
  { tenantId, approvalEpoch: tenant.approvalEpoch, kind, requestId, permissions, ownerDids }
1409
1565
  );
1410
1566
  if (reusable !== void 0) return reusable;
1411
- const nonce = (0, import_node_crypto.randomBytes)(24).toString("base64url");
1567
+ const nonce = (0, import_node_crypto2.randomBytes)(24).toString("base64url");
1412
1568
  const created = Date.now();
1413
1569
  const pending = {
1414
1570
  version: 1,
@@ -1433,7 +1589,7 @@ var RemoteTenantStore = class {
1433
1589
  const expected = this.#signState(tenantId, nonce);
1434
1590
  const supplied = Buffer.from(signature, "base64url");
1435
1591
  const expectedBytes = Buffer.from(expected, "base64url");
1436
- if (supplied.length !== expectedBytes.length || !(0, import_node_crypto.timingSafeEqual)(supplied, expectedBytes)) {
1592
+ if (supplied.length !== expectedBytes.length || !(0, import_node_crypto2.timingSafeEqual)(supplied, expectedBytes)) {
1437
1593
  throw new ApprovalRequestError("state_invalid", "Approval state is invalid.");
1438
1594
  }
1439
1595
  const stateRoot = (0, import_node_path3.join)(this.#config.stateDir, "tenants", tenantId);
@@ -1485,10 +1641,10 @@ var RemoteTenantStore = class {
1485
1641
  return url.toString();
1486
1642
  }
1487
1643
  #tenantId(subject) {
1488
- return (0, import_node_crypto.createHmac)("sha256", this.#config.stateSecret).update(`tenant:${subject}`).digest("hex");
1644
+ return (0, import_node_crypto2.createHmac)("sha256", this.#config.stateSecret).update(`tenant:${subject}`).digest("hex");
1489
1645
  }
1490
1646
  #signState(tenantId, nonce) {
1491
- return (0, import_node_crypto.createHmac)("sha256", this.#config.stateSecret).update(`approval:${tenantId}:${nonce}`).digest("base64url");
1647
+ return (0, import_node_crypto2.createHmac)("sha256", this.#config.stateSecret).update(`approval:${tenantId}:${nonce}`).digest("base64url");
1492
1648
  }
1493
1649
  #profilePath(stateRoot) {
1494
1650
  return (0, import_node_path3.join)(stateRoot, ".tinycloud", "profiles", PROFILE, "profile.json");
@@ -1590,7 +1746,7 @@ async function secureDirectory(path) {
1590
1746
  async function secureWrite(path, value) {
1591
1747
  const directory = (0, import_node_path3.dirname)(path);
1592
1748
  await secureDirectory(directory);
1593
- const temporary = `${path}.${process.pid}.${(0, import_node_crypto.randomBytes)(8).toString("hex")}.tmp`;
1749
+ const temporary = `${path}.${process.pid}.${(0, import_node_crypto2.randomBytes)(8).toString("hex")}.tmp`;
1594
1750
  await (0, import_promises.writeFile)(temporary, `${JSON.stringify(value, jsonReplacer, 2)}
1595
1751
  `, { mode: 384 });
1596
1752
  await (0, import_promises.rename)(temporary, path);
@@ -1642,12 +1798,13 @@ var REQUIRED_SCOPE = "tinycloud:mcp";
1642
1798
  function createHostedMcpApp(config) {
1643
1799
  const resourceUrl = canonicalResourceUrl(config.publicUrl);
1644
1800
  const pluginUrl = new URL("./chatgpt/mcp", resourceUrl);
1645
- const hosted = createHostedSurface(config, resourceUrl, "hosted");
1801
+ const statusFor = (resource) => config.introspection === void 0 ? void 0 : createTokenStatusChecker({ ...config.introspection, resourceUrl: resource.toString() });
1802
+ const hosted = createHostedSurface(config, resourceUrl, "hosted", statusFor(resourceUrl));
1646
1803
  const plugin = createHostedSurface({
1647
1804
  ...config,
1648
1805
  stateDir: (0, import_node_path4.join)(config.stateDir, "chatgpt"),
1649
1806
  approvalPath: new URL("./connect", pluginUrl).pathname
1650
- }, pluginUrl, "public");
1807
+ }, pluginUrl, "public", statusFor(pluginUrl));
1651
1808
  const pluginPaths = {
1652
1809
  [pluginUrl.pathname]: true,
1653
1810
  [new URL("./connect", pluginUrl).pathname]: true,
@@ -1661,7 +1818,7 @@ function createHostedMcpApp(config) {
1661
1818
  }
1662
1819
  };
1663
1820
  }
1664
- function createHostedSurface(config, resourceUrl, surface) {
1821
+ function createHostedSurface(config, resourceUrl, surface, tokenStatus) {
1665
1822
  const connectPath = config.approvalPath ?? "/connect";
1666
1823
  const callbackPath = `${connectPath}/callback`;
1667
1824
  const issuer = String(config.oauthMetadata.issuer);
@@ -1670,7 +1827,8 @@ function createHostedSurface(config, resourceUrl, surface) {
1670
1827
  issuer,
1671
1828
  audience: resourceUrl.toString(),
1672
1829
  jwksUri,
1673
- requiredScope: REQUIRED_SCOPE
1830
+ requiredScope: REQUIRED_SCOPE,
1831
+ ...tokenStatus === void 0 ? {} : { tokenStatus }
1674
1832
  });
1675
1833
  const resourceMetadataUrl = (0, import_server5.getOAuthProtectedResourceMetadataUrl)(resourceUrl);
1676
1834
  const authenticate = (0, import_server5.requireBearerAuth)({