@floomhq/floom-mcp-sync 1.0.1 → 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
@@ -13,12 +13,15 @@ Version 1 sync does not replace existing local Markdown files. Remote updates, e
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`)
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
@@ -5,12 +5,14 @@ import { autoSync } from "./auto-sync.js";
5
5
  import { installSkill } from "./tools/install.js";
6
6
  import { publishSkill } from "./tools/publish.js";
7
7
  import { listLibraries, moveSkill, subscribeLibrary, unsubscribeLibrary, } from "./tools/libraries.js";
8
- const SERVER_VERSION = "1.0.1";
8
+ import { searchSkills } from "./tools/search.js";
9
+ const SERVER_VERSION = "1.0.2";
9
10
  const DEFAULT_INTERVAL_MS = 60_000;
10
11
  const MIN_INTERVAL_MS = 10_000;
11
12
  const VERSION_RE = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,63}$/;
12
13
  const VISIBILITIES = new Set(["unlisted", "public", "private"]);
13
14
  const ASSET_TYPES = new Set(["knowledge", "instruction", "workflow", "skill"]);
15
+ const SEARCH_TYPES = new Set(["knowledge", "instruction", "workflow", "skill"]);
14
16
  const FOLDER_RE = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)*$/;
15
17
  const INSTALL_TARGETS = new Set([
16
18
  "claude_skill",
@@ -92,6 +94,21 @@ function startPolling(intervalMs, state) {
92
94
  function toolList() {
93
95
  return {
94
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
+ },
95
112
  {
96
113
  name: "floom_install_skill",
97
114
  description: "Fetch a Floom skill by slug and install it into ~/.claude/skills/.",
@@ -201,6 +218,13 @@ function enumValue(value, label, allowed, fallback) {
201
218
  throw new Error(`Invalid ${label}.`);
202
219
  return value;
203
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
+ }
204
228
  function nullableEnumValue(value, label, allowed, fallback) {
205
229
  if (value === undefined)
206
230
  return fallback;
@@ -217,6 +241,14 @@ function optionalStringArray(value, label, maxItems, maxLength) {
217
241
  throw new Error(`Invalid ${label}.`);
218
242
  return value.map((item) => asString(item, label, 1, maxLength));
219
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
+ }
220
252
  function nullableFolder(value) {
221
253
  if (value === null)
222
254
  return null;
@@ -232,6 +264,19 @@ async function callTool(params) {
232
264
  if (name === "floom_install_skill") {
233
265
  return ok(await installSkill(asString(args.slug, "slug", 1, 128)));
234
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
+ }
235
280
  if (name === "floom_publish_skill") {
236
281
  const version = optionalString(args.version, "version", 64);
237
282
  if (version !== undefined && !VERSION_RE.test(version))
@@ -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.1",
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",