@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 +11 -3
- package/dist/auto-sync.js +2 -2
- package/dist/server.js +127 -2
- package/dist/tools/search.js +66 -0
- package/package.json +1 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
+
}
|