@floomhq/floom-mcp-sync 1.0.0

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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Floom
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,28 @@
1
+ # Floom MCP Sync
2
+
3
+ Tiny MCP server for Floom skills. This package is part of the Floom Version 1 synchronization preview.
4
+
5
+ ```bash
6
+ npx -y @floomhq/floom-mcp-sync
7
+ ```
8
+
9
+ On startup it reads `~/.floom/config.json`, fetches your own published Floom skills, and writes missing files to `~/.claude/skills/`. The background sync behavior is a Version 1 preview path.
10
+
11
+ Sync stores a machine-local manifest next to the Floom CLI config at `~/.floom/sync-manifest.json`.
12
+ Version 1 sync does not replace existing local Markdown files. Remote updates, existing untracked
13
+ files, and locally edited tracked files are skipped as conflicts. Symlinks are never followed. Move
14
+ or delete the local file to accept the Floom version on the next sync.
15
+
16
+ The poll uses HTTP `If-None-Match` against `/api/me/skills`, so unchanged responses are 304 with no body. Steady-state polling is ~free on bandwidth. If a Floom-tracked local file is missing after a 304, MCP refetches once without the ETag so Version 1 can recreate missing files without overwriting existing Markdown.
17
+
18
+ Configure the preview poll interval with `FLOOM_SYNC_INTERVAL_MS` (default `60000`, minimum `10000`).
19
+
20
+ Tools:
21
+
22
+ - `floom_install_skill(slug)` fetches `/s/<slug>.md` and writes it locally.
23
+ - `floom_publish_skill(name, content, description?, visibility?, asset_type?, installs_as?, version?)` publishes Markdown through `/api/skills`.
24
+ - `asset_type`: `knowledge`, `instruction`, `workflow`, or `skill` (default `skill`)
25
+ - `installs_as`: `claude_skill`, `memory`, `rule`, `codex_instruction`, `opencode_instruction`, `cursor_rule`, or `other` (default `claude_skill`)
26
+ - `version`: optional label like `1.0.0` or `v1-preview`
27
+
28
+ Library management and share-invite tools are planned for a later Floom version.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ import "../dist/server.js";
@@ -0,0 +1,458 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, mkdir, open } from "node:fs/promises";
3
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { apiUrlFromConfig, readConfig } from "./lib/config.js";
5
+ import { getJsonWithEtag, FloomApiError } from "./lib/api.js";
6
+ import { sha256 } from "./lib/hash.js";
7
+ import { assertValidSlug } from "./lib/slug.js";
8
+ import { skillsDir, skillTargetPath } from "./lib/paths.js";
9
+ import { ensureSyncManifestDir, manifestKey, markSynced, readSyncManifest, unmarkSynced, writeSyncManifest } from "./lib/manifest.js";
10
+ // Module-level cache: ETag from the last successful (non-304) response, plus
11
+ // the last time we logged a heartbeat. Survives across setInterval ticks
12
+ // inside a single MCP server process.
13
+ let cachedEtag = null;
14
+ let lastHeartbeatAt = 0;
15
+ let lastAuthWarningAt = 0;
16
+ const HEARTBEAT_MS = 10 * 60 * 1000; // 10 minutes
17
+ const AUTH_WARNING_MS = 10 * 60 * 1000;
18
+ const MANIFEST_SEGMENT_RE = /^[A-Za-z0-9._-]{1,128}$/;
19
+ const FD_PATH_ROOT = "/proc/self/fd";
20
+ async function localState(path) {
21
+ try {
22
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
23
+ try {
24
+ const stat = await handle.stat();
25
+ if (!stat.isFile()) {
26
+ return { kind: "conflict", reason: "path is blocked by an existing local file or directory" };
27
+ }
28
+ return { kind: "file", hash: sha256(await handle.readFile("utf8")) };
29
+ }
30
+ finally {
31
+ await handle.close();
32
+ }
33
+ }
34
+ catch (err) {
35
+ const code = err.code;
36
+ if (code === "ENOENT")
37
+ return { kind: "missing" };
38
+ if (code === "ELOOP")
39
+ return { kind: "conflict", reason: "path is a symbolic link" };
40
+ if (code === "ENOTDIR" || code === "EISDIR") {
41
+ return { kind: "conflict", reason: "path is blocked by an existing local file or directory" };
42
+ }
43
+ throw err;
44
+ }
45
+ }
46
+ function targetFromManifestKey(root, key) {
47
+ if (!key || isAbsolute(key) || key.includes("\\") || key.length > 512) {
48
+ throw new Error("Invalid manifest target path");
49
+ }
50
+ const segments = key.split("/");
51
+ if (segments.some((segment) => segment === "." || segment === ".." || !MANIFEST_SEGMENT_RE.test(segment))) {
52
+ throw new Error("Invalid manifest target path");
53
+ }
54
+ const target = join(root, ...segments);
55
+ const relativeTarget = relative(resolve(root), resolve(target));
56
+ if (relativeTarget === ".." || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) {
57
+ throw new Error("Invalid manifest target path");
58
+ }
59
+ return target;
60
+ }
61
+ function validateSyncSkillShape(skill) {
62
+ if (!skill || typeof skill !== "object")
63
+ throw new Error("Invalid sync response");
64
+ const candidate = skill;
65
+ if (typeof candidate.slug !== "string" || typeof candidate.body_md !== "string") {
66
+ throw new Error("Invalid sync response");
67
+ }
68
+ if (candidate.folder !== undefined &&
69
+ candidate.folder !== null &&
70
+ typeof candidate.folder !== "string") {
71
+ throw new Error("Invalid sync response");
72
+ }
73
+ if (candidate.library_slug !== undefined &&
74
+ candidate.library_slug !== null &&
75
+ typeof candidate.library_slug !== "string") {
76
+ throw new Error("Invalid sync response");
77
+ }
78
+ }
79
+ async function manifestHasMissingTrackedFile(manifest, root) {
80
+ for (const key of Object.keys(manifest.files)) {
81
+ let target;
82
+ try {
83
+ target = targetFromManifestKey(root, key);
84
+ await assertSafeExistingParentDirectory(root, target);
85
+ }
86
+ catch {
87
+ continue;
88
+ }
89
+ const state = await localState(target);
90
+ if (state.kind === "missing")
91
+ return true;
92
+ }
93
+ return false;
94
+ }
95
+ export async function autoSync(log = (message) => process.stderr.write(`${message}\n`)) {
96
+ const cfg = await readConfig();
97
+ if (!cfg) {
98
+ maybeAuthWarning(log, "[floom] not signed in; skipping sync (run `floom login`)");
99
+ return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
100
+ }
101
+ await ensureSyncManifestDir();
102
+ const manifest = await readSyncManifest();
103
+ const root = skillsDir();
104
+ const apiUrl = apiUrlFromConfig(cfg);
105
+ let response;
106
+ try {
107
+ response = await getJsonWithEtag(`${apiUrl}/api/me/skills`, cfg.accessToken, cachedEtag);
108
+ }
109
+ catch (err) {
110
+ if (err instanceof FloomApiError && err.status === 401) {
111
+ maybeAuthWarning(log, "[floom] sign-in expired; skipping sync (run `floom login` again)");
112
+ return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
113
+ }
114
+ throw err;
115
+ }
116
+ if (response.status === 304) {
117
+ if (await manifestHasMissingTrackedFile(manifest, root)) {
118
+ response = await getJsonWithEtag(`${apiUrl}/api/me/skills`, cfg.accessToken, null);
119
+ }
120
+ else {
121
+ maybeHeartbeat(log);
122
+ return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
123
+ }
124
+ }
125
+ if (response.status === 304) {
126
+ maybeHeartbeat(log);
127
+ return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
128
+ }
129
+ if (response.etag)
130
+ cachedEtag = response.etag;
131
+ const payload = response.body;
132
+ if (!payload) {
133
+ return { synced: 0, unchanged: 0, updated: 0, conflicts: 0 };
134
+ }
135
+ await mkdir(skillsDir(), { recursive: true, mode: 0o700 });
136
+ if (!Array.isArray(payload.skills)) {
137
+ throw new Error("Invalid sync response");
138
+ }
139
+ for (const skill of payload.skills)
140
+ validateSyncSkillShape(skill);
141
+ // Version 1 preview syncs owned published skills only.
142
+ const buckets = [
143
+ { skills: payload.skills, defaultLib: null },
144
+ ];
145
+ let unchanged = 0;
146
+ let updated = 0;
147
+ let conflicts = 0;
148
+ let total = 0;
149
+ const writtenPaths = new Set();
150
+ const activeTargetKeys = new Set();
151
+ const pruneBlockedSlugs = new Set();
152
+ const noteConflict = (target, reason) => {
153
+ conflicts += 1;
154
+ log(`[floom] skipped local conflict: ${manifestKey(root, target)} (${reason})`);
155
+ };
156
+ const noteKeyConflict = (key, reason) => {
157
+ conflicts += 1;
158
+ log(`[floom] skipped local conflict: ${key} (${reason})`);
159
+ };
160
+ const noteRemoteConflict = (slug, reason) => {
161
+ conflicts += 1;
162
+ const label = typeof slug === "string" && slug ? slug : "<invalid>";
163
+ log(`[floom] skipped remote conflict: ${label} (${reason})`);
164
+ };
165
+ for (const bucket of buckets) {
166
+ const skills = bucket.skills ?? [];
167
+ for (const skill of skills) {
168
+ let target;
169
+ try {
170
+ assertValidSlug(skill.slug);
171
+ target = skillTargetPath({
172
+ slug: skill.slug,
173
+ folder: skill.folder ?? null,
174
+ librarySlug: skill.library_slug ?? bucket.defaultLib,
175
+ });
176
+ }
177
+ catch (err) {
178
+ if (typeof skill.slug === "string")
179
+ pruneBlockedSlugs.add(skill.slug);
180
+ noteRemoteConflict(skill.slug, err instanceof Error ? err.message : "invalid remote metadata");
181
+ continue;
182
+ }
183
+ if (writtenPaths.has(target))
184
+ continue;
185
+ writtenPaths.add(target);
186
+ total += 1;
187
+ const remoteHash = sha256(skill.body_md);
188
+ const targetKey = manifestKey(root, target);
189
+ activeTargetKeys.add(targetKey);
190
+ const tracked = manifest.files[targetKey];
191
+ try {
192
+ await assertSafeExistingParentDirectory(root, target);
193
+ }
194
+ catch (err) {
195
+ const code = err.code;
196
+ if (code === "ELOOP") {
197
+ noteConflict(target, "path contains a symbolic link");
198
+ continue;
199
+ }
200
+ if (code === "ENOTDIR" || code === "EISDIR") {
201
+ noteConflict(target, "path is blocked by an existing local file or directory");
202
+ continue;
203
+ }
204
+ if (code === "EEXIST" || code === "ENOENT") {
205
+ noteConflict(target, err instanceof Error ? err.message : "local file changed during Floom sync");
206
+ continue;
207
+ }
208
+ throw err;
209
+ }
210
+ const state = await localState(target);
211
+ if (state.kind === "conflict") {
212
+ noteConflict(target, state.reason);
213
+ continue;
214
+ }
215
+ if (state.kind === "file" && !tracked) {
216
+ noteConflict(target, "existing file is not tracked by Floom sync");
217
+ continue;
218
+ }
219
+ if (state.kind === "file" && state.hash !== tracked?.hash) {
220
+ noteConflict(target, "local file changed since the last Floom sync");
221
+ continue;
222
+ }
223
+ if (state.kind === "file" && state.hash === remoteHash) {
224
+ unchanged += 1;
225
+ continue;
226
+ }
227
+ if (state.kind === "file") {
228
+ noteConflict(target, "remote skill changed; move or delete the local file to accept the Floom version");
229
+ continue;
230
+ }
231
+ try {
232
+ await writeSyncedFile(target, skill.body_md);
233
+ }
234
+ catch (err) {
235
+ const code = err.code;
236
+ if (code === "ELOOP") {
237
+ noteConflict(target, "path contains a symbolic link");
238
+ continue;
239
+ }
240
+ if (code === "ENOTDIR" || code === "EISDIR") {
241
+ noteConflict(target, "path is blocked by an existing local file or directory");
242
+ continue;
243
+ }
244
+ throw err;
245
+ }
246
+ markSynced(manifest, targetKey, skill.slug, remoteHash);
247
+ await writeSyncManifest(manifest);
248
+ updated += 1;
249
+ }
250
+ }
251
+ if (payload.full_sync === true) {
252
+ for (const [key, entry] of Object.entries(manifest.files)) {
253
+ if (activeTargetKeys.has(key))
254
+ continue;
255
+ if (pruneBlockedSlugs.has(entry.slug)) {
256
+ noteKeyConflict(key, "remote metadata is invalid for this skill");
257
+ continue;
258
+ }
259
+ let target;
260
+ try {
261
+ target = targetFromManifestKey(root, key);
262
+ await assertSafeExistingParentDirectory(root, target);
263
+ }
264
+ catch (err) {
265
+ const code = err.code;
266
+ if (code === "ELOOP") {
267
+ noteKeyConflict(key, "path contains a symbolic link");
268
+ continue;
269
+ }
270
+ if (code === "ENOTDIR" || code === "EISDIR") {
271
+ noteKeyConflict(key, "path is blocked by an existing local file or directory");
272
+ continue;
273
+ }
274
+ noteKeyConflict(key, "invalid manifest target path");
275
+ continue;
276
+ }
277
+ const state = await localState(target);
278
+ if (state.kind === "missing") {
279
+ unmarkSynced(manifest, key);
280
+ await writeSyncManifest(manifest);
281
+ continue;
282
+ }
283
+ if (state.kind === "conflict") {
284
+ noteConflict(target, state.reason);
285
+ continue;
286
+ }
287
+ if (state.hash !== entry.hash) {
288
+ noteConflict(target, "local file changed since the last Floom sync");
289
+ continue;
290
+ }
291
+ unmarkSynced(manifest, key);
292
+ await writeSyncManifest(manifest);
293
+ }
294
+ }
295
+ // Only log when there's actual movement (skills updated) OR heartbeat is due.
296
+ // Steady-state polling stays quiet so it doesn't pollute MCP stderr.
297
+ if (updated > 0) {
298
+ const conflictNote = conflicts > 0 ? `, ${conflicts} conflicts skipped` : "";
299
+ log(`[floom] synced ${total} skills (${unchanged} unchanged, ${updated} updated${conflictNote})`);
300
+ lastHeartbeatAt = Date.now();
301
+ if (updated > 0) {
302
+ // Activation telemetry counts syncs that write new content. Best-effort;
303
+ // never blocks or throws.
304
+ void emitSyncCompleted(apiUrl, cfg.accessToken, { total, updated, unchanged }).catch(() => { });
305
+ }
306
+ }
307
+ else if (conflicts > 0) {
308
+ log(`[floom] synced ${total} skills (${unchanged} unchanged, ${updated} updated, ${conflicts} conflicts skipped)`);
309
+ lastHeartbeatAt = Date.now();
310
+ }
311
+ else {
312
+ maybeHeartbeat(log, () => `[floom] heartbeat: ${total} skills tracked, all up-to-date`);
313
+ }
314
+ return { synced: total, unchanged, updated, conflicts };
315
+ }
316
+ async function emitSyncCompleted(apiUrl, token, props) {
317
+ try {
318
+ await fetch(`${apiUrl}/api/v1/events`, {
319
+ method: "POST",
320
+ headers: {
321
+ "Content-Type": "application/json",
322
+ authorization: `Bearer ${token}`,
323
+ },
324
+ body: JSON.stringify({
325
+ event_name: "sync.completed",
326
+ source: "mcp",
327
+ props: {
328
+ total: props.total,
329
+ updated: props.updated,
330
+ unchanged: props.unchanged,
331
+ },
332
+ }),
333
+ });
334
+ }
335
+ catch {
336
+ // never throw from telemetry
337
+ }
338
+ }
339
+ async function writeSyncedFile(target, body) {
340
+ const parent = await openSafeParentDirectory(skillsDir(), target, true);
341
+ let handle = null;
342
+ try {
343
+ handle = await open(childCreatePath(parent, dirname(target), basename(target)), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
344
+ await writeAll(handle, body);
345
+ }
346
+ finally {
347
+ await handle?.close();
348
+ await parent.close();
349
+ }
350
+ }
351
+ async function openSafeParentDirectory(root, target, create) {
352
+ if (create)
353
+ await ensureSafeParentDirectory(root, target);
354
+ else
355
+ await assertSafeExistingParentDirectory(root, target);
356
+ return open(dirname(target), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
357
+ }
358
+ function childCreatePath(parent, fallbackParent, name) {
359
+ if (process.platform === "linux")
360
+ return `${FD_PATH_ROOT}/${parent.fd}/${name}`;
361
+ return join(resolve(fallbackParent), name);
362
+ }
363
+ async function writeAll(handle, body) {
364
+ const buffer = Buffer.from(body, "utf8");
365
+ let offset = 0;
366
+ while (offset < buffer.length) {
367
+ const result = await handle.write(buffer, offset, buffer.length - offset, offset);
368
+ if (result.bytesWritten === 0)
369
+ throw conflictError("failed to write local skill file", "EIO");
370
+ offset += result.bytesWritten;
371
+ }
372
+ }
373
+ async function ensureSafeParentDirectory(root, target) {
374
+ const resolvedRoot = resolve(root);
375
+ const resolvedParent = resolve(dirname(target));
376
+ const relativeParent = relative(resolvedRoot, resolvedParent);
377
+ if (relativeParent === ".." || relativeParent.startsWith(`..${sep}`) || isAbsolute(relativeParent)) {
378
+ throw conflictError("Invalid skill target path.", "EINVAL");
379
+ }
380
+ await mkdir(resolvedRoot, { recursive: true, mode: 0o700 });
381
+ await assertSafeDirectory(resolvedRoot);
382
+ if (!relativeParent || relativeParent === ".")
383
+ return;
384
+ let current = resolvedRoot;
385
+ for (const segment of relativeParent.split(sep).filter(Boolean)) {
386
+ current = join(current, segment);
387
+ try {
388
+ await assertSafeDirectory(current);
389
+ }
390
+ catch (err) {
391
+ if (err.code !== "ENOENT")
392
+ throw err;
393
+ await mkdir(current, { mode: 0o700 });
394
+ await assertSafeDirectory(current);
395
+ }
396
+ }
397
+ }
398
+ async function assertSafeExistingParentDirectory(root, target) {
399
+ const resolvedRoot = resolve(root);
400
+ const resolvedParent = resolve(dirname(target));
401
+ const relativeParent = relative(resolvedRoot, resolvedParent);
402
+ if (relativeParent === ".." || relativeParent.startsWith(`..${sep}`) || isAbsolute(relativeParent)) {
403
+ throw conflictError("Invalid skill target path.", "EINVAL");
404
+ }
405
+ await assertSafeDirectory(resolvedRoot);
406
+ if (!relativeParent || relativeParent === ".")
407
+ return;
408
+ let current = resolvedRoot;
409
+ for (const segment of relativeParent.split(sep).filter(Boolean)) {
410
+ current = join(current, segment);
411
+ try {
412
+ await assertSafeDirectory(current);
413
+ }
414
+ catch (err) {
415
+ if (err.code === "ENOENT")
416
+ return;
417
+ throw err;
418
+ }
419
+ }
420
+ }
421
+ async function assertSafeDirectory(path) {
422
+ const stat = await lstat(path);
423
+ if (stat.isSymbolicLink())
424
+ throw conflictError("path contains a symbolic link", "ELOOP");
425
+ if (!stat.isDirectory()) {
426
+ throw conflictError("path is blocked by an existing local file or directory", "ENOTDIR");
427
+ }
428
+ }
429
+ function conflictError(message, code) {
430
+ const err = new Error(message);
431
+ err.code = code;
432
+ return err;
433
+ }
434
+ function maybeHeartbeat(log, message) {
435
+ const now = Date.now();
436
+ if (lastHeartbeatAt === 0) {
437
+ // Initialise without logging; first sync result was already logged or was a 304.
438
+ lastHeartbeatAt = now;
439
+ return;
440
+ }
441
+ if (now - lastHeartbeatAt < HEARTBEAT_MS)
442
+ return;
443
+ if (message)
444
+ log(message());
445
+ lastHeartbeatAt = now;
446
+ }
447
+ function maybeAuthWarning(log, message) {
448
+ const now = Date.now();
449
+ if (lastAuthWarningAt !== 0 && now - lastAuthWarningAt < AUTH_WARNING_MS)
450
+ return;
451
+ log(message);
452
+ lastAuthWarningAt = now;
453
+ }
454
+ // TODO(option-B): swap polling for SSE. Add `/api/v1/sync-stream` route that
455
+ // holds a per-user EventSource open and pushes `{type:"skills-updated"}` from a
456
+ // Supabase post-write trigger or webhook. MCP listens, calls autoSync() on
457
+ // event. Drops sync latency from <=60s to <=2s. Trade-off: needs reconnect
458
+ // logic, Vercel's 5-min connection cap, and a server-side change-fanout path.
@@ -0,0 +1,90 @@
1
+ export class FloomApiError extends Error {
2
+ status;
3
+ constructor(status, message) {
4
+ super(message);
5
+ this.status = status;
6
+ this.name = "FloomApiError";
7
+ }
8
+ }
9
+ async function readError(res) {
10
+ const text = await res.text();
11
+ if (!text)
12
+ return `Floom API returned HTTP ${res.status}`;
13
+ try {
14
+ const parsed = JSON.parse(text);
15
+ if (typeof parsed.error === "string")
16
+ return parsed.error;
17
+ }
18
+ catch {
19
+ return text;
20
+ }
21
+ return text;
22
+ }
23
+ export async function getJson(url, token) {
24
+ const headers = {};
25
+ if (token)
26
+ headers.authorization = `Bearer ${token}`;
27
+ const res = await fetch(url, { headers });
28
+ if (!res.ok)
29
+ throw new FloomApiError(res.status, await readError(res));
30
+ return (await res.json());
31
+ }
32
+ /**
33
+ * GET helper that participates in HTTP conditional requests via ETag.
34
+ * Pass the previously-seen ETag (or null on first call). On 304, body is null.
35
+ */
36
+ export async function getJsonWithEtag(url, token, etag) {
37
+ const headers = { authorization: `Bearer ${token}` };
38
+ if (etag)
39
+ headers["if-none-match"] = etag;
40
+ const res = await fetch(url, { headers });
41
+ const responseEtag = res.headers.get("etag");
42
+ if (res.status === 304) {
43
+ return { status: 304, body: null, etag: responseEtag ?? etag };
44
+ }
45
+ if (!res.ok)
46
+ throw new FloomApiError(res.status, await readError(res));
47
+ const body = (await res.json());
48
+ return { status: res.status, body, etag: responseEtag };
49
+ }
50
+ export async function getText(url) {
51
+ const res = await fetch(url);
52
+ if (!res.ok)
53
+ throw new FloomApiError(res.status, await readError(res));
54
+ return res.text();
55
+ }
56
+ export async function postJson(url, token, body) {
57
+ const res = await fetch(url, {
58
+ method: "POST",
59
+ headers: {
60
+ authorization: `Bearer ${token}`,
61
+ "content-type": "application/json",
62
+ },
63
+ body: JSON.stringify(body),
64
+ });
65
+ if (!res.ok)
66
+ throw new FloomApiError(res.status, await readError(res));
67
+ return (await res.json());
68
+ }
69
+ export async function putJson(url, token, body) {
70
+ const res = await fetch(url, {
71
+ method: "PUT",
72
+ headers: {
73
+ authorization: `Bearer ${token}`,
74
+ "content-type": "application/json",
75
+ },
76
+ body: JSON.stringify(body),
77
+ });
78
+ if (!res.ok)
79
+ throw new FloomApiError(res.status, await readError(res));
80
+ return (await res.json());
81
+ }
82
+ export async function deleteRequest(url, token) {
83
+ const res = await fetch(url, {
84
+ method: "DELETE",
85
+ headers: { authorization: `Bearer ${token}` },
86
+ });
87
+ if (!res.ok)
88
+ throw new FloomApiError(res.status, await readError(res));
89
+ return (await res.json());
90
+ }
@@ -0,0 +1,26 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { configPath } from "./paths.js";
3
+ export const DEFAULT_API_URL = "https://floom.dev";
4
+ export function apiUrlFromConfig(cfg) {
5
+ return (cfg.apiUrl ?? process.env.FLOOM_API_URL ?? DEFAULT_API_URL).replace(/\/$/, "");
6
+ }
7
+ export async function readConfig() {
8
+ try {
9
+ const raw = await readFile(configPath(), "utf8");
10
+ const parsed = JSON.parse(raw);
11
+ if (typeof parsed.accessToken !== "string" || parsed.accessToken.length === 0)
12
+ return null;
13
+ return {
14
+ accessToken: parsed.accessToken,
15
+ ...(typeof parsed.apiUrl === "string" ? { apiUrl: parsed.apiUrl } : {}),
16
+ ...(typeof parsed.refreshToken === "string" ? { refreshToken: parsed.refreshToken } : {}),
17
+ ...(typeof parsed.expiresAt === "number" ? { expiresAt: parsed.expiresAt } : {}),
18
+ ...(typeof parsed.email === "string" || parsed.email === null ? { email: parsed.email } : {}),
19
+ };
20
+ }
21
+ catch (err) {
22
+ if (err.code === "ENOENT")
23
+ return null;
24
+ throw err;
25
+ }
26
+ }
@@ -0,0 +1,4 @@
1
+ import { createHash } from "node:crypto";
2
+ export function sha256(input) {
3
+ return createHash("sha256").update(input).digest("hex");
4
+ }
@@ -0,0 +1,129 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, mkdir, open, rename } from "node:fs/promises";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
+ import { configPath } from "./paths.js";
5
+ const MANIFEST_VERSION = 1;
6
+ const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
7
+ const FD_PATH_ROOT = "/proc/self/fd";
8
+ function manifestPath() {
9
+ return join(dirname(configPath()), "sync-manifest.json");
10
+ }
11
+ function emptyManifest() {
12
+ return { version: MANIFEST_VERSION, files: {} };
13
+ }
14
+ function isEntryForKey(key, value) {
15
+ if (!value || typeof value !== "object")
16
+ return false;
17
+ const entry = value;
18
+ return (typeof entry.hash === "string" &&
19
+ typeof entry.slug === "string" &&
20
+ typeof entry.target === "string" &&
21
+ typeof entry.syncedAt === "string" &&
22
+ entry.target === key &&
23
+ SLUG_RE.test(entry.slug) &&
24
+ key.split("/").at(-1) === `${entry.slug}.md`);
25
+ }
26
+ export async function readSyncManifest() {
27
+ try {
28
+ await ensureSyncManifestDir();
29
+ const handle = await open(manifestPath(), constants.O_RDONLY | constants.O_NOFOLLOW);
30
+ let body;
31
+ try {
32
+ body = await handle.readFile("utf8");
33
+ }
34
+ finally {
35
+ await handle.close();
36
+ }
37
+ const parsed = JSON.parse(body);
38
+ if (parsed.version !== MANIFEST_VERSION || !parsed.files || typeof parsed.files !== "object") {
39
+ return emptyManifest();
40
+ }
41
+ const manifest = emptyManifest();
42
+ for (const [key, value] of Object.entries(parsed.files)) {
43
+ if (isEntryForKey(key, value))
44
+ manifest.files[key] = value;
45
+ }
46
+ return manifest;
47
+ }
48
+ catch (err) {
49
+ if (err.code === "ENOENT")
50
+ return emptyManifest();
51
+ if (err instanceof SyntaxError)
52
+ return emptyManifest();
53
+ throw err;
54
+ }
55
+ }
56
+ export async function writeSyncManifest(manifest) {
57
+ await ensureSyncManifestDir();
58
+ const path = manifestPath();
59
+ const dirPath = dirname(path);
60
+ const dir = await open(dirPath, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
61
+ const tmpBase = `sync-manifest.json.${process.pid}.${Date.now()}`;
62
+ const body = JSON.stringify(manifest, null, 2);
63
+ try {
64
+ for (let attempt = 0; attempt < 10; attempt += 1) {
65
+ const tmpName = attempt === 0 ? `${tmpBase}.tmp` : `${tmpBase}.${attempt}.tmp`;
66
+ const tmpPath = childPath(dir, dirPath, tmpName);
67
+ let handle = null;
68
+ try {
69
+ handle = await open(tmpPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
70
+ await handle.writeFile(body, "utf8");
71
+ await handle.close();
72
+ handle = null;
73
+ await rename(tmpPath, childPath(dir, dirPath, "sync-manifest.json"));
74
+ return;
75
+ }
76
+ catch (err) {
77
+ await handle?.close().catch(() => { });
78
+ if (err.code === "EEXIST")
79
+ continue;
80
+ throw err;
81
+ }
82
+ }
83
+ }
84
+ finally {
85
+ await dir.close();
86
+ }
87
+ const err = new Error("temporary sync manifest file already exists");
88
+ err.code = "EEXIST";
89
+ throw err;
90
+ }
91
+ function childPath(parent, fallbackParent, name) {
92
+ if (process.platform === "linux")
93
+ return `${FD_PATH_ROOT}/${parent.fd}/${name}`;
94
+ return join(resolve(fallbackParent), name);
95
+ }
96
+ export async function ensureSyncManifestDir() {
97
+ const path = manifestPath();
98
+ const dir = dirname(path);
99
+ await mkdir(dir, { recursive: true, mode: 0o700 });
100
+ const stat = await lstat(dir);
101
+ if (stat.isSymbolicLink()) {
102
+ const err = new Error("sync manifest directory is a symbolic link");
103
+ err.code = "ELOOP";
104
+ throw err;
105
+ }
106
+ if (!stat.isDirectory()) {
107
+ const err = new Error("sync manifest path is blocked by an existing local file");
108
+ err.code = "ENOTDIR";
109
+ throw err;
110
+ }
111
+ }
112
+ export function manifestKey(root, target) {
113
+ const relativeTarget = relative(resolve(root), resolve(target));
114
+ if (relativeTarget === ".." || relativeTarget.startsWith(`..${sep}`)) {
115
+ throw new Error("Invalid manifest target path.");
116
+ }
117
+ return relativeTarget.split(sep).join("/");
118
+ }
119
+ export function markSynced(manifest, key, slug, hash) {
120
+ manifest.files[key] = {
121
+ hash,
122
+ slug,
123
+ target: key,
124
+ syncedAt: new Date().toISOString(),
125
+ };
126
+ }
127
+ export function unmarkSynced(manifest, key) {
128
+ delete manifest.files[key];
129
+ }
@@ -0,0 +1,46 @@
1
+ import { homedir } from "node:os";
2
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
3
+ import { assertValidSlug } from "./slug.js";
4
+ export function configPath() {
5
+ return process.env.FLOOM_CONFIG_PATH ?? join(homedir(), ".floom", "config.json");
6
+ }
7
+ export function skillsDir() {
8
+ return process.env.CLAUDE_SKILLS_DIR ?? join(homedir(), ".claude", "skills");
9
+ }
10
+ export function skillPath(slug) {
11
+ return join(skillsDir(), `${slug}.md`);
12
+ }
13
+ const PATH_SEGMENT_RE = /^[a-z0-9._-]{1,128}$/;
14
+ function safePathSegments(value, label) {
15
+ if (!value)
16
+ return [];
17
+ if (isAbsolute(value))
18
+ throw new Error(`Invalid ${label}`);
19
+ const segments = value.split(/[\\/]+/).filter(Boolean);
20
+ if (segments.some((segment) => segment === "." || segment === ".." || !PATH_SEGMENT_RE.test(segment))) {
21
+ throw new Error(`Invalid ${label}`);
22
+ }
23
+ return segments;
24
+ }
25
+ /**
26
+ * Compute the on-disk path for a skill given optional folder + library
27
+ * grouping. Version 1 preview uses owned skills at the skills root or inside
28
+ * `<folder>/`. Library grouping remains a later-version path shape.
29
+ *
30
+ * The slug always becomes the filename. Folder/library segments must already
31
+ * be validated by the API (server-side regex enforces lowercase tokens).
32
+ */
33
+ export function skillTargetPath(opts) {
34
+ assertValidSlug(opts.slug);
35
+ const root = skillsDir();
36
+ const segments = [root];
37
+ segments.push(...safePathSegments(opts.librarySlug, "library slug"));
38
+ segments.push(...safePathSegments(opts.folder, "folder"));
39
+ segments.push(`${opts.slug}.md`);
40
+ const target = join(...segments);
41
+ const relativeTarget = relative(resolve(root), resolve(target));
42
+ if (relativeTarget === ".." || relativeTarget.startsWith(`..${sep}`) || isAbsolute(relativeTarget)) {
43
+ throw new Error("Invalid skill target path");
44
+ }
45
+ return target;
46
+ }
@@ -0,0 +1,6 @@
1
+ const SLUG_RE = /^[A-Za-z0-9_-]{1,128}$/;
2
+ export function assertValidSlug(slug) {
3
+ if (!SLUG_RE.test(slug)) {
4
+ throw new Error("Invalid skill slug");
5
+ }
6
+ }
package/dist/server.js ADDED
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env node
2
+ import { createInterface } from "node:readline";
3
+ import { stdin, stdout } from "node:process";
4
+ import { autoSync } from "./auto-sync.js";
5
+ import { installSkill } from "./tools/install.js";
6
+ import { publishSkill } from "./tools/publish.js";
7
+ const SERVER_VERSION = "1.0.0";
8
+ const DEFAULT_INTERVAL_MS = 60_000;
9
+ const MIN_INTERVAL_MS = 10_000;
10
+ const VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
11
+ const VISIBILITIES = new Set(["unlisted", "public", "private"]);
12
+ const ASSET_TYPES = new Set(["knowledge", "instruction", "workflow", "skill"]);
13
+ const INSTALL_TARGETS = new Set([
14
+ "claude_skill",
15
+ "memory",
16
+ "rule",
17
+ "codex_instruction",
18
+ "opencode_instruction",
19
+ "cursor_rule",
20
+ "other",
21
+ ]);
22
+ function usage() {
23
+ return `
24
+ floom-mcp-sync v${SERVER_VERSION}
25
+ Floom MCP server for Version 1 preview sync.
26
+
27
+ Usage
28
+ floom-mcp-sync
29
+
30
+ Behavior
31
+ Starts a stdio MCP server.
32
+ Syncs your own published Floom skills into ~/.claude/skills/ on startup.
33
+ Polls for updates while the MCP process is running.
34
+
35
+ Options
36
+ --help, -h Show this help.
37
+ --version Print the version.
38
+
39
+ Env
40
+ FLOOM_API_URL Override the API host.
41
+ FLOOM_SYNC_INTERVAL_MS Poll interval in milliseconds. Minimum: 10000.
42
+ `.trimStart();
43
+ }
44
+ function handleCliArgs(argv) {
45
+ if (argv.includes("--version")) {
46
+ stdout.write(`${SERVER_VERSION}\n`);
47
+ return true;
48
+ }
49
+ if (argv.includes("--help") || argv.includes("-h")) {
50
+ stdout.write(usage());
51
+ return true;
52
+ }
53
+ return false;
54
+ }
55
+ function ok(payload) {
56
+ return {
57
+ content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
58
+ structuredContent: payload,
59
+ };
60
+ }
61
+ function resolvePollIntervalMs() {
62
+ const raw = process.env.FLOOM_SYNC_INTERVAL_MS;
63
+ if (!raw)
64
+ return DEFAULT_INTERVAL_MS;
65
+ if (!/^\d+$/.test(raw)) {
66
+ process.stderr.write(`[floom] invalid FLOOM_SYNC_INTERVAL_MS=${raw}, using ${DEFAULT_INTERVAL_MS}ms\n`);
67
+ return DEFAULT_INTERVAL_MS;
68
+ }
69
+ const parsed = Number.parseInt(raw, 10);
70
+ if (parsed < MIN_INTERVAL_MS) {
71
+ process.stderr.write(`[floom] FLOOM_SYNC_INTERVAL_MS=${parsed} below minimum, clamping to ${MIN_INTERVAL_MS}ms\n`);
72
+ return MIN_INTERVAL_MS;
73
+ }
74
+ return parsed;
75
+ }
76
+ function startPolling(intervalMs, state) {
77
+ return setInterval(() => {
78
+ if (state.inFlight) {
79
+ process.stderr.write("[floom] previous sync poll still running; skipping this tick\n");
80
+ return;
81
+ }
82
+ state.inFlight = true;
83
+ autoSync().catch((err) => {
84
+ process.stderr.write(`[floom] poll failed: ${err instanceof Error ? err.message : String(err)}\n`);
85
+ }).finally(() => {
86
+ state.inFlight = false;
87
+ });
88
+ }, intervalMs);
89
+ }
90
+ function toolList() {
91
+ return {
92
+ tools: [
93
+ {
94
+ name: "floom_install_skill",
95
+ description: "Fetch a Floom skill by slug and install it into ~/.claude/skills/.",
96
+ inputSchema: {
97
+ type: "object",
98
+ properties: {
99
+ slug: { type: "string", minLength: 1, maxLength: 128 },
100
+ },
101
+ required: ["slug"],
102
+ additionalProperties: false,
103
+ },
104
+ },
105
+ {
106
+ name: "floom_publish_skill",
107
+ description: "Publish Markdown content to Floom as a skill.",
108
+ inputSchema: {
109
+ type: "object",
110
+ properties: {
111
+ name: { type: "string", minLength: 1, maxLength: 200 },
112
+ content: { type: "string", minLength: 1, maxLength: 500_000 },
113
+ description: { type: "string", maxLength: 1000 },
114
+ visibility: { type: "string", enum: [...VISIBILITIES] },
115
+ asset_type: { type: "string", enum: [...ASSET_TYPES] },
116
+ installs_as: { type: ["string", "null"], enum: [...INSTALL_TARGETS, null] },
117
+ version: { type: "string", pattern: "^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$" },
118
+ },
119
+ required: ["name", "content"],
120
+ additionalProperties: false,
121
+ },
122
+ },
123
+ ],
124
+ };
125
+ }
126
+ function asObject(value) {
127
+ if (!value || typeof value !== "object" || Array.isArray(value))
128
+ throw new Error("Expected object arguments.");
129
+ return value;
130
+ }
131
+ function asString(value, label, min, max) {
132
+ if (typeof value !== "string" || value.length < min || value.length > max) {
133
+ throw new Error(`Invalid ${label}.`);
134
+ }
135
+ return value;
136
+ }
137
+ function optionalString(value, label, max) {
138
+ if (value === undefined)
139
+ return undefined;
140
+ if (typeof value !== "string" || value.length > max)
141
+ throw new Error(`Invalid ${label}.`);
142
+ return value;
143
+ }
144
+ function enumValue(value, label, allowed, fallback) {
145
+ if (value === undefined)
146
+ return fallback;
147
+ if (typeof value !== "string" || !allowed.has(value))
148
+ throw new Error(`Invalid ${label}.`);
149
+ return value;
150
+ }
151
+ function nullableEnumValue(value, label, allowed, fallback) {
152
+ if (value === undefined)
153
+ return fallback;
154
+ if (value === null)
155
+ return null;
156
+ if (typeof value !== "string" || !allowed.has(value))
157
+ throw new Error(`Invalid ${label}.`);
158
+ return value;
159
+ }
160
+ async function callTool(params) {
161
+ const parsed = asObject(params);
162
+ const name = asString(parsed.name, "tool name", 1, 200);
163
+ const args = asObject(parsed.arguments ?? {});
164
+ if (name === "floom_install_skill") {
165
+ return ok(await installSkill(asString(args.slug, "slug", 1, 128)));
166
+ }
167
+ if (name === "floom_publish_skill") {
168
+ const version = optionalString(args.version, "version", 64);
169
+ if (version !== undefined && !VERSION_RE.test(version))
170
+ throw new Error("Invalid version.");
171
+ return ok(await publishSkill(asString(args.name, "name", 1, 200), asString(args.content, "content", 1, 500_000), optionalString(args.description, "description", 1000), enumValue(args.visibility, "visibility", VISIBILITIES, "unlisted"), enumValue(args.asset_type, "asset_type", ASSET_TYPES, "skill"), nullableEnumValue(args.installs_as, "installs_as", INSTALL_TARGETS, "claude_skill"), version));
172
+ }
173
+ throw new Error(`Unknown tool: ${name}`);
174
+ }
175
+ function response(id, result) {
176
+ return `${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`;
177
+ }
178
+ function errorResponse(id, code, message) {
179
+ return `${JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } })}\n`;
180
+ }
181
+ function isJsonRpcId(raw) {
182
+ return typeof raw === "string" || typeof raw === "number";
183
+ }
184
+ async function handleRequest(message) {
185
+ if (message.id === undefined)
186
+ return;
187
+ const id = isJsonRpcId(message.id) ? message.id : null;
188
+ if (message.jsonrpc !== "2.0" || !isJsonRpcId(message.id) || typeof message.method !== "string") {
189
+ stdout.write(errorResponse(id, -32600, "Invalid request."));
190
+ return;
191
+ }
192
+ try {
193
+ if (message.method === "initialize") {
194
+ stdout.write(response(id, {
195
+ protocolVersion: "2025-06-18",
196
+ capabilities: { tools: {} },
197
+ serverInfo: { name: "floom-mcp-sync", version: SERVER_VERSION },
198
+ }));
199
+ return;
200
+ }
201
+ if (message.method === "ping") {
202
+ stdout.write(response(id, {}));
203
+ return;
204
+ }
205
+ if (message.method === "tools/list") {
206
+ stdout.write(response(id, toolList()));
207
+ return;
208
+ }
209
+ if (message.method === "tools/call") {
210
+ stdout.write(response(id, await callTool(message.params)));
211
+ return;
212
+ }
213
+ stdout.write(errorResponse(id, -32601, `Method not found: ${message.method}`));
214
+ }
215
+ catch (err) {
216
+ stdout.write(errorResponse(id, -32000, err instanceof Error ? err.message : String(err)));
217
+ }
218
+ }
219
+ async function main() {
220
+ if (handleCliArgs(process.argv.slice(2)))
221
+ return;
222
+ const intervalMs = resolvePollIntervalMs();
223
+ process.stderr.write(`[floom] starting sync poller (interval ${intervalMs}ms)\n`);
224
+ const syncState = { inFlight: true };
225
+ void autoSync().catch((err) => {
226
+ process.stderr.write(`[floom] initial sync failed: ${err instanceof Error ? err.message : String(err)}\n`);
227
+ }).finally(() => {
228
+ syncState.inFlight = false;
229
+ });
230
+ const pollHandle = startPolling(intervalMs, syncState);
231
+ const shutdown = (signal) => {
232
+ clearInterval(pollHandle);
233
+ process.stderr.write(`[floom] received ${signal}, stopping sync poller\n`);
234
+ process.exit(0);
235
+ };
236
+ process.once("SIGINT", () => shutdown("SIGINT"));
237
+ process.once("SIGTERM", () => shutdown("SIGTERM"));
238
+ const rl = createInterface({ input: stdin, crlfDelay: Infinity });
239
+ for await (const line of rl) {
240
+ if (!line.trim())
241
+ continue;
242
+ try {
243
+ const message = JSON.parse(line);
244
+ if (!message || typeof message !== "object" || Array.isArray(message)) {
245
+ stdout.write(errorResponse(null, -32600, "Invalid request."));
246
+ continue;
247
+ }
248
+ await handleRequest(message);
249
+ }
250
+ catch {
251
+ stdout.write(errorResponse(null, -32700, "Parse error."));
252
+ }
253
+ }
254
+ }
255
+ main().catch((err) => {
256
+ process.stderr.write(`[floom] fatal: ${err instanceof Error ? err.message : String(err)}\n`);
257
+ process.exit(1);
258
+ });
@@ -0,0 +1,117 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, mkdir, open } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
5
+ import { apiUrlFromConfig, readConfig, DEFAULT_API_URL } from "../lib/config.js";
6
+ import { getText } from "../lib/api.js";
7
+ import { assertValidSlug } from "../lib/slug.js";
8
+ import { skillPath, skillsDir } from "../lib/paths.js";
9
+ const FD_PATH_ROOT = "/proc/self/fd";
10
+ export async function installSkill(slug) {
11
+ assertValidSlug(slug);
12
+ const cfg = await readConfig();
13
+ const apiUrl = cfg ? apiUrlFromConfig(cfg) : (process.env.FLOOM_API_URL ?? DEFAULT_API_URL).replace(/\/$/, "");
14
+ const body = await getText(`${apiUrl}/s/${slug}.md`);
15
+ if (typeof body !== "string")
16
+ throw new Error("Invalid skill response");
17
+ const target = skillPath(slug);
18
+ const remoteHash = sha256(body);
19
+ const existing = await localHash(target);
20
+ if (existing !== null && existing !== remoteHash) {
21
+ throw new Error("Local skill already exists with different content");
22
+ }
23
+ if (existing === null)
24
+ await writeInstallFile(target, body);
25
+ return { slug, path: target, bytes: Buffer.byteLength(body) };
26
+ }
27
+ function sha256(input) {
28
+ return createHash("sha256").update(input).digest("hex");
29
+ }
30
+ async function localHash(path) {
31
+ try {
32
+ const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
33
+ try {
34
+ const stat = await handle.stat();
35
+ if (!stat.isFile())
36
+ throw new Error("path is blocked by an existing local file or directory");
37
+ return sha256(await handle.readFile("utf8"));
38
+ }
39
+ finally {
40
+ await handle.close();
41
+ }
42
+ }
43
+ catch (err) {
44
+ if (err.code === "ENOENT")
45
+ return null;
46
+ throw err;
47
+ }
48
+ }
49
+ async function writeInstallFile(target, body) {
50
+ const parent = await openSafeParentDirectory(skillsDir(), target);
51
+ let handle = null;
52
+ try {
53
+ handle = await open(childCreatePath(parent, dirname(target), basename(target)), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
54
+ await writeAll(handle, body);
55
+ }
56
+ finally {
57
+ await handle?.close();
58
+ await parent.close();
59
+ }
60
+ }
61
+ async function openSafeParentDirectory(root, target) {
62
+ await ensureSafeParentDirectory(root, target);
63
+ return open(dirname(target), constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
64
+ }
65
+ function childCreatePath(parent, fallbackParent, name) {
66
+ if (process.platform === "linux")
67
+ return `${FD_PATH_ROOT}/${parent.fd}/${name}`;
68
+ return join(resolve(fallbackParent), name);
69
+ }
70
+ async function writeAll(handle, body) {
71
+ const buffer = Buffer.from(body, "utf8");
72
+ let offset = 0;
73
+ while (offset < buffer.length) {
74
+ const result = await handle.write(buffer, offset, buffer.length - offset, offset);
75
+ if (result.bytesWritten === 0)
76
+ throw new Error("failed to write local skill file");
77
+ offset += result.bytesWritten;
78
+ }
79
+ }
80
+ async function ensureSafeParentDirectory(root, target) {
81
+ const resolvedRoot = resolve(root);
82
+ const resolvedParent = resolve(dirname(target));
83
+ const relativeParent = relative(resolvedRoot, resolvedParent);
84
+ if (relativeParent === ".." || relativeParent.startsWith(`..${sep}`) || isAbsolute(relativeParent)) {
85
+ throw new Error("Invalid skill target path");
86
+ }
87
+ await mkdir(resolvedRoot, { recursive: true, mode: 0o700 });
88
+ await assertSafeDirectory(resolvedRoot);
89
+ if (!relativeParent || relativeParent === ".")
90
+ return;
91
+ let current = resolvedRoot;
92
+ for (const segment of relativeParent.split(sep).filter(Boolean)) {
93
+ current = join(current, segment);
94
+ try {
95
+ await assertSafeDirectory(current);
96
+ }
97
+ catch (err) {
98
+ if (err.code !== "ENOENT")
99
+ throw err;
100
+ await mkdir(current, { mode: 0o700 });
101
+ await assertSafeDirectory(current);
102
+ }
103
+ }
104
+ }
105
+ async function assertSafeDirectory(path) {
106
+ const stat = await lstat(path);
107
+ if (stat.isSymbolicLink()) {
108
+ const err = new Error("path contains a symbolic link");
109
+ err.code = "ELOOP";
110
+ throw err;
111
+ }
112
+ if (!stat.isDirectory()) {
113
+ const err = new Error("path is blocked by an existing local file or directory");
114
+ err.code = "ENOTDIR";
115
+ throw err;
116
+ }
117
+ }
@@ -0,0 +1,28 @@
1
+ import { apiUrlFromConfig, readConfig, DEFAULT_API_URL } from "../lib/config.js";
2
+ import { deleteRequest, getJson, postJson, putJson } from "../lib/api.js";
3
+ export async function listLibraries() {
4
+ const cfg = await readConfig();
5
+ const apiUrl = cfg ? apiUrlFromConfig(cfg) : (process.env.FLOOM_API_URL ?? DEFAULT_API_URL).replace(/\/$/, "");
6
+ return await getJson(`${apiUrl}/api/v1/libraries`, cfg?.accessToken);
7
+ }
8
+ export async function subscribeLibrary(slug) {
9
+ const cfg = await readConfig();
10
+ if (!cfg)
11
+ throw new Error("Not signed in. Run `floom login` first.");
12
+ const apiUrl = apiUrlFromConfig(cfg);
13
+ return await postJson(`${apiUrl}/api/v1/me/subscriptions`, cfg.accessToken, { library_slug: slug });
14
+ }
15
+ export async function unsubscribeLibrary(slug) {
16
+ const cfg = await readConfig();
17
+ if (!cfg)
18
+ throw new Error("Not signed in. Run `floom login` first.");
19
+ const apiUrl = apiUrlFromConfig(cfg);
20
+ return await deleteRequest(`${apiUrl}/api/v1/me/subscriptions/${encodeURIComponent(slug)}`, cfg.accessToken);
21
+ }
22
+ export async function moveSkill(slug, folder, tags) {
23
+ const cfg = await readConfig();
24
+ if (!cfg)
25
+ throw new Error("Not signed in. Run `floom login` first.");
26
+ const apiUrl = apiUrlFromConfig(cfg);
27
+ return await putJson(`${apiUrl}/api/v1/me/skills/${encodeURIComponent(slug)}/override`, cfg.accessToken, { folder, tags });
28
+ }
@@ -0,0 +1,24 @@
1
+ import { apiUrlFromConfig, readConfig } from "../lib/config.js";
2
+ import { postJson } from "../lib/api.js";
3
+ export async function publishSkill(name, content, description, visibility = "unlisted", assetType = "skill", installsAs = "claude_skill", version) {
4
+ const cfg = await readConfig();
5
+ if (!cfg)
6
+ throw new Error("Not signed in. Run `floom login` first.");
7
+ const apiUrl = apiUrlFromConfig(cfg);
8
+ const data = await postJson(`${apiUrl}/api/skills`, cfg.accessToken, {
9
+ title: name,
10
+ description: description ?? null,
11
+ body_md: content,
12
+ visibility,
13
+ asset_type: assetType,
14
+ source: "markdown",
15
+ installs_as: installsAs,
16
+ version: version ?? null,
17
+ published_via: "mcp",
18
+ });
19
+ return {
20
+ slug: data.slug,
21
+ url: data.url ?? `${apiUrl}/s/${data.slug}.md`,
22
+ ...(data.version ? { version: data.version } : {}),
23
+ };
24
+ }
package/package.json ADDED
@@ -0,0 +1,45 @@
1
+ {
2
+ "name": "@floomhq/floom-mcp-sync",
3
+ "version": "1.0.0",
4
+ "description": "Lightweight Floom MCP server for installing, publishing, and startup-syncing skills.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "bin": {
8
+ "floom-mcp-sync": "bin/floom-mcp-sync.js"
9
+ },
10
+ "files": [
11
+ "bin",
12
+ "dist",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "engines": {
17
+ "node": ">=20"
18
+ },
19
+ "scripts": {
20
+ "build": "tsc",
21
+ "test": "npm run build && node --test test/*.mjs",
22
+ "pack:check": "npm pack --dry-run",
23
+ "prepack": "npm run build",
24
+ "prepublishOnly": "npm run build"
25
+ },
26
+ "dependencies": {},
27
+ "devDependencies": {
28
+ "@types/node": "22.10.5",
29
+ "typescript": "5.7.3"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public"
33
+ },
34
+ "repository": {
35
+ "type": "git",
36
+ "url": "git+https://github.com/floomhq/floom.git",
37
+ "directory": "mcp-sync"
38
+ },
39
+ "keywords": [
40
+ "floom",
41
+ "mcp",
42
+ "claude",
43
+ "skills"
44
+ ]
45
+ }