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