@floomhq/floom-mcp-sync 1.0.0 → 1.0.2

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/README.md CHANGED
@@ -6,23 +6,31 @@ Tiny MCP server for Floom skills. This package is part of the Floom Version 1 sy
6
6
  npx -y @floomhq/floom-mcp-sync
7
7
  ```
8
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.
9
+ On startup it reads `~/.floom/config.json`, fetches published, saved, and subscribed library skills, and writes missing files to `~/.claude/skills/`. The background sync behavior is a Version 1 preview path.
10
10
 
11
11
  Sync stores a machine-local manifest next to the Floom CLI config at `~/.floom/sync-manifest.json`.
12
12
  Version 1 sync does not replace existing local Markdown files. Remote updates, existing untracked
13
13
  files, and locally edited tracked files are skipped as conflicts. Symlinks are never followed. Move
14
14
  or delete the local file to accept the Floom version on the next sync.
15
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.
16
+ The poll uses HTTP `If-None-Match` against `/api/v1/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
17
 
18
18
  Configure the preview poll interval with `FLOOM_SYNC_INTERVAL_MS` (default `60000`, minimum `10000`).
19
19
 
20
20
  Tools:
21
21
 
22
+ - `floom_search_skills(query, library?, type?, limit?)` searches `/api/v1/search` and returns compact skill hits with slug, title, description, library placement, folder, and install URL.
23
+ - `type`: `knowledge`, `instruction`, `workflow`, or `skill`
24
+ - `limit`: 1-50
22
25
  - `floom_install_skill(slug)` fetches `/s/<slug>.md` and writes it locally.
23
26
  - `floom_publish_skill(name, content, description?, visibility?, asset_type?, installs_as?, version?)` publishes Markdown through `/api/skills`.
24
27
  - `asset_type`: `knowledge`, `instruction`, `workflow`, or `skill` (default `skill`)
25
28
  - `installs_as`: `claude_skill`, `memory`, `rule`, `codex_instruction`, `opencode_instruction`, `cursor_rule`, or `other` (default `claude_skill`)
26
29
  - `version`: optional label like `1.0.0` or `v1-preview`
27
30
 
28
- Library management and share-invite tools are planned for a later Floom version.
31
+ - `floom_list_libraries()` lists public Floom libraries.
32
+ - `floom_subscribe_library(slug)` subscribes the signed-in user so the library syncs locally.
33
+ - `floom_unsubscribe_library(slug)` removes that subscription.
34
+ - `floom_move_skill(slug, folder, tags?)` sets the signed-in user's local folder/tags override.
35
+
36
+ Team workspaces, share invites, and role-based library access are planned for a later Floom version.
package/dist/auto-sync.js CHANGED
@@ -104,7 +104,7 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
104
104
  const apiUrl = apiUrlFromConfig(cfg);
105
105
  let response;
106
106
  try {
107
- response = await getJsonWithEtag(`${apiUrl}/api/me/skills`, cfg.accessToken, cachedEtag);
107
+ response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, cachedEtag);
108
108
  }
109
109
  catch (err) {
110
110
  if (err instanceof FloomApiError && err.status === 401) {
@@ -115,7 +115,7 @@ export async function autoSync(log = (message) => process.stderr.write(`${messag
115
115
  }
116
116
  if (response.status === 304) {
117
117
  if (await manifestHasMissingTrackedFile(manifest, root)) {
118
- response = await getJsonWithEtag(`${apiUrl}/api/me/skills`, cfg.accessToken, null);
118
+ response = await getJsonWithEtag(`${apiUrl}/api/v1/me/skills`, cfg.accessToken, null);
119
119
  }
120
120
  else {
121
121
  maybeHeartbeat(log);
package/dist/server.js CHANGED
@@ -4,12 +4,16 @@ import { stdin, stdout } from "node:process";
4
4
  import { autoSync } from "./auto-sync.js";
5
5
  import { installSkill } from "./tools/install.js";
6
6
  import { publishSkill } from "./tools/publish.js";
7
- const SERVER_VERSION = "1.0.0";
7
+ import { listLibraries, moveSkill, subscribeLibrary, unsubscribeLibrary, } from "./tools/libraries.js";
8
+ import { searchSkills } from "./tools/search.js";
9
+ const SERVER_VERSION = "1.0.2";
8
10
  const DEFAULT_INTERVAL_MS = 60_000;
9
11
  const MIN_INTERVAL_MS = 10_000;
10
12
  const VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
11
13
  const VISIBILITIES = new Set(["unlisted", "public", "private"]);
12
14
  const ASSET_TYPES = new Set(["knowledge", "instruction", "workflow", "skill"]);
15
+ const SEARCH_TYPES = new Set(["knowledge", "instruction", "workflow", "skill"]);
16
+ const FOLDER_RE = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)*$/;
13
17
  const INSTALL_TARGETS = new Set([
14
18
  "claude_skill",
15
19
  "memory",
@@ -29,7 +33,7 @@ Usage
29
33
 
30
34
  Behavior
31
35
  Starts a stdio MCP server.
32
- Syncs your own published Floom skills into ~/.claude/skills/ on startup.
36
+ Syncs published, saved, and subscribed library skills into ~/.claude/skills/.
33
37
  Polls for updates while the MCP process is running.
34
38
 
35
39
  Options
@@ -90,6 +94,21 @@ function startPolling(intervalMs, state) {
90
94
  function toolList() {
91
95
  return {
92
96
  tools: [
97
+ {
98
+ name: "floom_search_skills",
99
+ description: "Search public Floom skills through the live API.",
100
+ inputSchema: {
101
+ type: "object",
102
+ properties: {
103
+ query: { type: "string", minLength: 1, maxLength: 120 },
104
+ library: { type: "string", minLength: 1, maxLength: 64 },
105
+ type: { type: "string", enum: [...SEARCH_TYPES] },
106
+ limit: { type: "integer", minimum: 1, maximum: 50 },
107
+ },
108
+ required: ["query"],
109
+ additionalProperties: false,
110
+ },
111
+ },
93
112
  {
94
113
  name: "floom_install_skill",
95
114
  description: "Fetch a Floom skill by slug and install it into ~/.claude/skills/.",
@@ -120,6 +139,57 @@ function toolList() {
120
139
  additionalProperties: false,
121
140
  },
122
141
  },
142
+ {
143
+ name: "floom_list_libraries",
144
+ description: "List public Floom libraries.",
145
+ inputSchema: {
146
+ type: "object",
147
+ properties: {},
148
+ additionalProperties: false,
149
+ },
150
+ },
151
+ {
152
+ name: "floom_subscribe_library",
153
+ description: "Subscribe the signed-in user to a Floom library so it syncs locally.",
154
+ inputSchema: {
155
+ type: "object",
156
+ properties: {
157
+ slug: { type: "string", minLength: 1, maxLength: 64 },
158
+ },
159
+ required: ["slug"],
160
+ additionalProperties: false,
161
+ },
162
+ },
163
+ {
164
+ name: "floom_unsubscribe_library",
165
+ description: "Unsubscribe the signed-in user from a Floom library.",
166
+ inputSchema: {
167
+ type: "object",
168
+ properties: {
169
+ slug: { type: "string", minLength: 1, maxLength: 64 },
170
+ },
171
+ required: ["slug"],
172
+ additionalProperties: false,
173
+ },
174
+ },
175
+ {
176
+ name: "floom_move_skill",
177
+ description: "Set a signed-in user's local folder/tags override for a Floom skill.",
178
+ inputSchema: {
179
+ type: "object",
180
+ properties: {
181
+ slug: { type: "string", minLength: 1, maxLength: 128 },
182
+ folder: { type: ["string", "null"], maxLength: 256 },
183
+ tags: {
184
+ type: "array",
185
+ items: { type: "string", minLength: 1, maxLength: 64 },
186
+ maxItems: 32,
187
+ },
188
+ },
189
+ required: ["slug", "folder"],
190
+ additionalProperties: false,
191
+ },
192
+ },
123
193
  ],
124
194
  };
125
195
  }
@@ -148,6 +218,13 @@ function enumValue(value, label, allowed, fallback) {
148
218
  throw new Error(`Invalid ${label}.`);
149
219
  return value;
150
220
  }
221
+ function optionalEnumValue(value, label, allowed) {
222
+ if (value === undefined)
223
+ return undefined;
224
+ if (typeof value !== "string" || !allowed.has(value))
225
+ throw new Error(`Invalid ${label}.`);
226
+ return value;
227
+ }
151
228
  function nullableEnumValue(value, label, allowed, fallback) {
152
229
  if (value === undefined)
153
230
  return fallback;
@@ -157,6 +234,29 @@ function nullableEnumValue(value, label, allowed, fallback) {
157
234
  throw new Error(`Invalid ${label}.`);
158
235
  return value;
159
236
  }
237
+ function optionalStringArray(value, label, maxItems, maxLength) {
238
+ if (value === undefined)
239
+ return [];
240
+ if (!Array.isArray(value) || value.length > maxItems)
241
+ throw new Error(`Invalid ${label}.`);
242
+ return value.map((item) => asString(item, label, 1, maxLength));
243
+ }
244
+ function optionalInteger(value, label, min, max) {
245
+ if (value === undefined)
246
+ return undefined;
247
+ if (typeof value !== "number" || !Number.isInteger(value) || value < min || value > max) {
248
+ throw new Error(`Invalid ${label}.`);
249
+ }
250
+ return value;
251
+ }
252
+ function nullableFolder(value) {
253
+ if (value === null)
254
+ return null;
255
+ const folder = asString(value, "folder", 1, 256);
256
+ if (!FOLDER_RE.test(folder))
257
+ throw new Error("Invalid folder.");
258
+ return folder;
259
+ }
160
260
  async function callTool(params) {
161
261
  const parsed = asObject(params);
162
262
  const name = asString(parsed.name, "tool name", 1, 200);
@@ -164,12 +264,37 @@ async function callTool(params) {
164
264
  if (name === "floom_install_skill") {
165
265
  return ok(await installSkill(asString(args.slug, "slug", 1, 128)));
166
266
  }
267
+ if (name === "floom_search_skills") {
268
+ const opts = {};
269
+ const library = optionalString(args.library, "library", 64);
270
+ const type = optionalEnumValue(args.type, "type", SEARCH_TYPES);
271
+ const limit = optionalInteger(args.limit, "limit", 1, 50);
272
+ if (library !== undefined)
273
+ opts.library = library;
274
+ if (type !== undefined)
275
+ opts.type = type;
276
+ if (limit !== undefined)
277
+ opts.limit = limit;
278
+ return ok(await searchSkills(asString(args.query, "query", 1, 120), opts));
279
+ }
167
280
  if (name === "floom_publish_skill") {
168
281
  const version = optionalString(args.version, "version", 64);
169
282
  if (version !== undefined && !VERSION_RE.test(version))
170
283
  throw new Error("Invalid version.");
171
284
  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
285
  }
286
+ if (name === "floom_list_libraries") {
287
+ return ok(await listLibraries());
288
+ }
289
+ if (name === "floom_subscribe_library") {
290
+ return ok(await subscribeLibrary(asString(args.slug, "slug", 1, 64)));
291
+ }
292
+ if (name === "floom_unsubscribe_library") {
293
+ return ok(await unsubscribeLibrary(asString(args.slug, "slug", 1, 64)));
294
+ }
295
+ if (name === "floom_move_skill") {
296
+ return ok(await moveSkill(asString(args.slug, "slug", 1, 128), nullableFolder(args.folder), optionalStringArray(args.tags, "tags", 32, 64)));
297
+ }
173
298
  throw new Error(`Unknown tool: ${name}`);
174
299
  }
175
300
  function response(id, result) {
@@ -0,0 +1,66 @@
1
+ import { apiUrlFromConfig, readConfig, DEFAULT_API_URL } from "../lib/config.js";
2
+ import { getJson } from "../lib/api.js";
3
+ export async function searchSkills(query, opts = {}) {
4
+ const cfg = await readConfig();
5
+ const apiUrl = cfg ? apiUrlFromConfig(cfg) : (process.env.FLOOM_API_URL ?? DEFAULT_API_URL).replace(/\/$/, "");
6
+ const params = new URLSearchParams({ q: query });
7
+ if (opts.library)
8
+ params.set("library", opts.library);
9
+ if (opts.type)
10
+ params.set("type", opts.type);
11
+ if (opts.limit !== undefined)
12
+ params.set("limit", String(opts.limit));
13
+ const payload = await getJson(`${apiUrl}/api/v1/search?${params.toString()}`, cfg?.accessToken);
14
+ return normalizeSearchResponse(payload, opts.limit ?? 20);
15
+ }
16
+ function normalizeSearchResponse(payload, fallbackLimit) {
17
+ const rawSkills = Array.isArray(payload.skills) ? payload.skills : [];
18
+ const rawLibraries = Array.isArray(payload.libraries) ? payload.libraries : [];
19
+ return {
20
+ query: typeof payload.query === "string" ? payload.query : "",
21
+ total: typeof payload.total === "number" ? payload.total : rawSkills.length + rawLibraries.length,
22
+ limit: typeof payload.limit === "number" ? payload.limit : fallbackLimit,
23
+ skills: rawSkills.flatMap((skill) => {
24
+ if (typeof skill.slug !== "string" || skill.slug.length === 0)
25
+ return [];
26
+ const libraries = normalizePlacements(skill.libraries);
27
+ const primary = libraries[0] ?? null;
28
+ return [{
29
+ slug: skill.slug,
30
+ title: typeof skill.title === "string" ? skill.title : null,
31
+ description: typeof skill.description === "string" ? skill.description : null,
32
+ type: isSkillType(skill.asset_type) ? skill.asset_type : "skill",
33
+ library: primary?.slug ?? null,
34
+ folder: primary?.folder ?? null,
35
+ install_url: typeof skill.url === "string" ? skill.url : null,
36
+ libraries,
37
+ }];
38
+ }),
39
+ libraries: rawLibraries.flatMap((library) => {
40
+ if (typeof library.slug !== "string" || library.slug.length === 0)
41
+ return [];
42
+ return [{
43
+ slug: library.slug,
44
+ name: typeof library.name === "string" ? library.name : null,
45
+ description: typeof library.description === "string" ? library.description : null,
46
+ skill_count: typeof library.skill_count === "number" ? library.skill_count : 0,
47
+ }];
48
+ }),
49
+ };
50
+ }
51
+ function normalizePlacements(raw) {
52
+ if (!Array.isArray(raw))
53
+ return [];
54
+ return raw.flatMap((placement) => {
55
+ if (typeof placement.slug !== "string" || placement.slug.length === 0)
56
+ return [];
57
+ return [{
58
+ slug: placement.slug,
59
+ name: typeof placement.name === "string" ? placement.name : null,
60
+ folder: typeof placement.folder === "string" ? placement.folder : null,
61
+ }];
62
+ });
63
+ }
64
+ function isSkillType(value) {
65
+ return value === "knowledge" || value === "instruction" || value === "workflow" || value === "skill";
66
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@floomhq/floom-mcp-sync",
3
- "version": "1.0.0",
3
+ "version": "1.0.2",
4
4
  "description": "Lightweight Floom MCP server for installing, publishing, and startup-syncing skills.",
5
5
  "license": "MIT",
6
6
  "type": "module",