@floomhq/floom-mcp-sync 1.0.12 → 1.0.13

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/auto-sync.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { constants } from "node:fs";
2
2
  import { lstat, mkdir, open } from "node:fs/promises";
3
3
  import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
- import { apiUrlFromConfig, readConfig } from "./lib/config.js";
4
+ import { apiUrlFromConfig, readConfig, refreshConfig } from "./lib/config.js";
5
5
  import { getJsonWithEtag, FloomApiError } from "./lib/api.js";
6
6
  import { sha256 } from "./lib/hash.js";
7
7
  import { assertValidSlug } from "./lib/slug.js";
@@ -254,21 +254,29 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
254
254
  return await withSyncLock(async () => {
255
255
  const manifest = await readSyncManifest();
256
256
  const root = skillsDir();
257
- const apiUrl = apiUrlFromConfig(cfg);
257
+ let activeCfg = cfg;
258
+ const apiUrl = apiUrlFromConfig(activeCfg);
258
259
  let response;
259
260
  try {
260
- response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, cachedEtag, signal);
261
+ response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, activeCfg.accessToken, cachedEtag, signal);
261
262
  }
262
263
  catch (err) {
263
264
  if (err instanceof FloomApiError && err.status === 401) {
264
- maybeAuthWarning(log, "[floom] sign-in expired; skipping sync (run `npx -y @floomhq/floom login` again)");
265
- return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
265
+ const refreshed = await refreshConfig(activeCfg);
266
+ if (!refreshed) {
267
+ maybeAuthWarning(log, "[floom] sign-in expired; skipping sync (run `npx -y @floomhq/floom login` again)");
268
+ return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
269
+ }
270
+ activeCfg = refreshed;
271
+ response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, activeCfg.accessToken, cachedEtag, signal);
272
+ }
273
+ else {
274
+ throw err;
266
275
  }
267
- throw err;
268
276
  }
269
277
  if (response.status === 304) {
270
278
  if (await manifestHasMissingTrackedFile(manifest, root)) {
271
- response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, null, signal);
279
+ response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, activeCfg.accessToken, null, signal);
272
280
  }
273
281
  else {
274
282
  maybeHeartbeat(log);
@@ -428,7 +436,7 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
428
436
  if (updated > 0) {
429
437
  // Activation telemetry counts syncs that write new content. Best-effort;
430
438
  // never blocks or throws.
431
- void emitSyncCompleted(apiUrl, cfg.accessToken, { total, updated, unchanged }, signal).catch(() => { });
439
+ void emitSyncCompleted(apiUrl, activeCfg.accessToken, { total, updated, unchanged }, signal).catch(() => { });
432
440
  }
433
441
  }
434
442
  else if (conflicts > 0) {
package/dist/lib/api.js CHANGED
@@ -6,6 +6,53 @@ export class FloomApiError extends Error {
6
6
  this.name = "FloomApiError";
7
7
  }
8
8
  }
9
+ export const DEFAULT_TIMEOUT_MS = 15_000;
10
+ function withTimeout(signal) {
11
+ const controller = new AbortController();
12
+ const timeoutMs = requestTimeoutMs();
13
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
14
+ const abort = () => controller.abort();
15
+ signal?.addEventListener("abort", abort, { once: true });
16
+ if (signal?.aborted)
17
+ controller.abort();
18
+ const aborted = new Promise((_, reject) => {
19
+ controller.signal.addEventListener("abort", () => {
20
+ reject(new FloomApiError(408, "Request timed out while contacting Floom."));
21
+ }, { once: true });
22
+ });
23
+ return {
24
+ signal: controller.signal,
25
+ aborted,
26
+ cleanup: () => {
27
+ clearTimeout(timer);
28
+ signal?.removeEventListener("abort", abort);
29
+ },
30
+ };
31
+ }
32
+ function requestTimeoutMs() {
33
+ const fromEnv = Number(process.env.FLOOM_REQUEST_TIMEOUT_MS);
34
+ if (Number.isFinite(fromEnv) && fromEnv > 0)
35
+ return fromEnv;
36
+ return DEFAULT_TIMEOUT_MS;
37
+ }
38
+ async function fetchWithTimeout(url, init, signal) {
39
+ const timeout = withTimeout(signal);
40
+ try {
41
+ return await Promise.race([
42
+ fetch(url, { ...init, signal: timeout.signal }),
43
+ timeout.aborted,
44
+ ]);
45
+ }
46
+ catch (err) {
47
+ if (err.name === "AbortError") {
48
+ throw new FloomApiError(408, "Request timed out while contacting Floom.");
49
+ }
50
+ throw err;
51
+ }
52
+ finally {
53
+ timeout.cleanup();
54
+ }
55
+ }
9
56
  async function readError(res) {
10
57
  const text = await res.text();
11
58
  if (!text)
@@ -24,10 +71,7 @@ export async function getJson(url, token, signal) {
24
71
  const headers = {};
25
72
  if (token)
26
73
  headers.authorization = `Bearer ${token}`;
27
- const init = { headers };
28
- if (signal)
29
- init.signal = signal;
30
- const res = await fetch(url, init);
74
+ const res = await fetchWithTimeout(url, { headers }, signal);
31
75
  if (!res.ok)
32
76
  throw new FloomApiError(res.status, await readError(res));
33
77
  return (await res.json());
@@ -40,10 +84,7 @@ export async function getJsonWithEtag(url, token, etag, signal) {
40
84
  const headers = { authorization: `Bearer ${token}` };
41
85
  if (etag)
42
86
  headers["if-none-match"] = etag;
43
- const init = { headers };
44
- if (signal)
45
- init.signal = signal;
46
- const res = await fetch(url, init);
87
+ const res = await fetchWithTimeout(url, { headers }, signal);
47
88
  const responseEtag = res.headers.get("etag");
48
89
  if (res.status === 304) {
49
90
  return { status: 304, body: null, etag: responseEtag ?? etag };
@@ -54,10 +95,7 @@ export async function getJsonWithEtag(url, token, etag, signal) {
54
95
  return { status: res.status, body, etag: responseEtag };
55
96
  }
56
97
  export async function getText(url, signal) {
57
- const init = {};
58
- if (signal)
59
- init.signal = signal;
60
- const res = await fetch(url, init);
98
+ const res = await fetchWithTimeout(url, {}, signal);
61
99
  if (!res.ok)
62
100
  throw new FloomApiError(res.status, await readError(res));
63
101
  return res.text();
@@ -71,9 +109,7 @@ export async function postJson(url, token, body, signal) {
71
109
  },
72
110
  body: JSON.stringify(body),
73
111
  };
74
- if (signal)
75
- init.signal = signal;
76
- const res = await fetch(url, init);
112
+ const res = await fetchWithTimeout(url, init, signal);
77
113
  if (!res.ok)
78
114
  throw new FloomApiError(res.status, await readError(res));
79
115
  return (await res.json());
@@ -87,9 +123,7 @@ export async function putJson(url, token, body, signal) {
87
123
  },
88
124
  body: JSON.stringify(body),
89
125
  };
90
- if (signal)
91
- init.signal = signal;
92
- const res = await fetch(url, init);
126
+ const res = await fetchWithTimeout(url, init, signal);
93
127
  if (!res.ok)
94
128
  throw new FloomApiError(res.status, await readError(res));
95
129
  return (await res.json());
@@ -99,9 +133,7 @@ export async function deleteRequest(url, token, signal) {
99
133
  method: "DELETE",
100
134
  headers: { authorization: `Bearer ${token}` },
101
135
  };
102
- if (signal)
103
- init.signal = signal;
104
- const res = await fetch(url, init);
136
+ const res = await fetchWithTimeout(url, init, signal);
105
137
  if (!res.ok)
106
138
  throw new FloomApiError(res.status, await readError(res));
107
139
  return (await res.json());
@@ -1,8 +1,10 @@
1
- import { readFile } from "node:fs/promises";
1
+ import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname } from "node:path";
2
3
  import { configPath } from "./paths.js";
3
4
  export const DEFAULT_API_URL = "https://floom.dev";
5
+ const REFRESH_SKEW_SECONDS = 5 * 60;
4
6
  export function apiUrlFromConfig(cfg) {
5
- return (cfg.apiUrl ?? process.env.FLOOM_API_URL ?? DEFAULT_API_URL).replace(/\/$/, "");
7
+ return (process.env.FLOOM_API_URL ?? cfg.apiUrl ?? DEFAULT_API_URL).replace(/\/$/, "");
6
8
  }
7
9
  export async function readConfig() {
8
10
  try {
@@ -10,13 +12,16 @@ export async function readConfig() {
10
12
  const parsed = JSON.parse(raw);
11
13
  if (typeof parsed.accessToken !== "string" || parsed.accessToken.length === 0)
12
14
  return null;
13
- return {
15
+ const cfg = {
14
16
  accessToken: parsed.accessToken,
15
17
  ...(typeof parsed.apiUrl === "string" ? { apiUrl: parsed.apiUrl } : {}),
16
18
  ...(typeof parsed.refreshToken === "string" ? { refreshToken: parsed.refreshToken } : {}),
17
19
  ...(typeof parsed.expiresAt === "number" ? { expiresAt: parsed.expiresAt } : {}),
18
20
  ...(typeof parsed.email === "string" || parsed.email === null ? { email: parsed.email } : {}),
19
21
  };
22
+ if (needsRefresh(cfg))
23
+ return refreshConfig(cfg);
24
+ return cfg;
20
25
  }
21
26
  catch (err) {
22
27
  if (err.code === "ENOENT")
@@ -24,3 +29,53 @@ export async function readConfig() {
24
29
  throw err;
25
30
  }
26
31
  }
32
+ export function needsRefresh(cfg) {
33
+ if (!cfg.refreshToken)
34
+ return false;
35
+ return !Number.isFinite(cfg.expiresAt) || Number(cfg.expiresAt) <= Math.floor(Date.now() / 1000) + REFRESH_SKEW_SECONDS;
36
+ }
37
+ export async function refreshConfig(cfg) {
38
+ if (!cfg.refreshToken)
39
+ return cfg;
40
+ const apiUrl = apiUrlFromConfig(cfg);
41
+ let res;
42
+ try {
43
+ res = await fetch(`${apiUrl}/api/v1/auth/refresh`, {
44
+ method: "POST",
45
+ headers: { "content-type": "application/json" },
46
+ body: JSON.stringify({ refresh_token: cfg.refreshToken }),
47
+ });
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ if (!res.ok)
53
+ return null;
54
+ let data;
55
+ try {
56
+ data = await res.json();
57
+ }
58
+ catch {
59
+ return null;
60
+ }
61
+ if (typeof data.access_token !== "string" || typeof data.refresh_token !== "string")
62
+ return null;
63
+ const expiresAt = typeof data.expires_at === "number" && Number.isFinite(data.expires_at)
64
+ ? data.expires_at
65
+ : Math.floor(Date.now() / 1000) + (typeof data.expires_in === "number" && Number.isFinite(data.expires_in) ? data.expires_in : 3600);
66
+ const refreshed = {
67
+ apiUrl,
68
+ accessToken: data.access_token,
69
+ refreshToken: data.refresh_token,
70
+ expiresAt,
71
+ email: typeof data.email === "string" || data.email === null ? data.email : cfg.email ?? null,
72
+ };
73
+ await writeConfig(refreshed);
74
+ return refreshed;
75
+ }
76
+ async function writeConfig(cfg) {
77
+ const target = configPath();
78
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
79
+ await writeFile(target, JSON.stringify(cfg, null, 2), { mode: 0o600 });
80
+ await chmod(target, 0o600);
81
+ }
package/dist/server.js CHANGED
@@ -5,7 +5,7 @@ import { autoSync } from "./auto-sync.js";
5
5
  import { getSkill } from "./tools/get.js";
6
6
  import { searchSkills } from "./tools/search.js";
7
7
  import { syncStatus } from "./tools/status.js";
8
- const SERVER_VERSION = "1.0.12";
8
+ const SERVER_VERSION = "1.0.13";
9
9
  const DEFAULT_INTERVAL_MS = 60_000;
10
10
  const MIN_INTERVAL_MS = 10_000;
11
11
  const SEARCH_TYPES = new Set(["knowledge", "instruction", "workflow", "skill"]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/floom-mcp-sync",
3
- "version": "1.0.12",
3
+ "version": "1.0.13",
4
4
  "description": "Lightweight Floom MCP server for search, on-demand skill fetch, status, and sync.",
5
5
  "license": "MIT",
6
6
  "type": "module",