@tpsdev-ai/flair-mcp 0.53.0 → 0.54.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/dist/index.js CHANGED
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * Flair MCP Server — persistent memory for Claude Code and any MCP client.
4
4
  *
5
- * Tools:
5
+ * Tools (derived from the vendored descriptor module — flair#1580, flair#1683):
6
6
  * - memory_search — semantic search across memories
7
7
  * - memory_store — save a memory with type + durability
8
8
  * - memory_update — update an existing memory by ID (dedup-bypassed)
@@ -14,6 +14,7 @@
14
14
  * - soul_get — get a personality/context entry
15
15
  * - flair_workspace_set — write own WorkspaceState (Office Space coordination)
16
16
  * - flair_orgevent — publish an OrgEvent attributed to self (no forging)
17
+ * - flair_catchup — drain + ack self's own OrgEventCatchup feed (owner-scoped)
17
18
  * - record_usage — report that recalled memories were actually used (flair#1147)
18
19
  * - skill_store — write a skill-tagged memory (trigger + procedure)
19
20
  * - skill_search — catalog skills that apply to a task (not the procedure)
@@ -44,54 +45,12 @@
44
45
  */
45
46
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
46
47
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
47
- import { FlairClient, FlairError, formatKeyLookup, inspectKeyLookup } from "@tpsdev-ai/flair-client";
48
- import { z } from "zod";
49
- import { deriveActivity, postPresenceSafe, resolveHeartbeatIntervalMs, resolvePresenceTimeoutMs, shouldSendHeartbeat, } from "./presence.js";
48
+ import { FlairClient } from "@tpsdev-ai/flair-client";
49
+ import { postPresenceSafe, resolveHeartbeatIntervalMs, resolvePresenceTimeoutMs, shouldSendHeartbeat, } from "./presence.js";
50
50
  import { readEnvOrUnset, stripInterpolationLiteralsFromEnv } from "./env-guard.js";
51
- import { buildRecordUsageBody, citationIds, withCiteNudge, RECORD_USAGE_ID_MERGE_CONTRACT } from "./usage.js";
52
51
  import { serverInfo } from "./version.js";
53
- import { buildSkillSearchBody, buildSkillStoreBody, formatSkillCatalog, isSkillRecord, projectSkillSearchResponse, stripInternalMemoryFields, } from "./skills.js";
54
- // ─── Error helpers ──────────────────────────────────────────────────────────
55
- export function classifyError(err, flairUrl) {
56
- if (err instanceof FlairError) {
57
- const { status, body } = err;
58
- if (status === 400)
59
- return `validation_error: ${body}`;
60
- if (status === 401 || status === 403) {
61
- // flair#1271: name the agent, the paths that were looked in, and the
62
- // remedy. A cached-miss / wrong-HOME 401 is not a daemon-restart hint.
63
- const lookup = err.keyLookup ?? {
64
- ...inspectKeyLookup(readEnvOrUnset("FLAIR_AGENT_ID") ?? "", readEnvOrUnset("FLAIR_KEY_PATH")),
65
- signed: false,
66
- authMethod: "none",
67
- };
68
- return `auth_error: ${body}\n${formatKeyLookup(lookup)}`;
69
- }
70
- if (status === 413)
71
- return `payload_too_large: ${body}`;
72
- if (status === 429)
73
- return "rate_limited — retry after a moment";
74
- if (status >= 500)
75
- return `server_error (retriable): ${body}`;
76
- return `http_error (${status}): ${body}`;
77
- }
78
- if (err instanceof Error) {
79
- if (err.name.includes("Abort") || err.name.includes("Timeout")) {
80
- return "timeout — the server took too long. This often happens with large content that requires embedding. Try shorter content or retry.";
81
- }
82
- if (err instanceof TypeError && err.message.includes("fetch")) {
83
- return `connection_error (retriable): could not reach Flair at ${flairUrl}. Is it running?\n` +
84
- `(Diagnostics:\n` +
85
- ` - 'curl ${flairUrl}/Health' — if this responds 200 or 401, daemon is up + this is an auth issue not a connection one.\n` +
86
- ` - 'launchctl list | grep flair' (macOS) or 'systemctl status flair' (Linux).)`;
87
- }
88
- return `unexpected_error: ${err.message}`;
89
- }
90
- return `unexpected_error: ${String(err)}`;
91
- }
92
- function errorResult(err, flairUrl) {
93
- return { content: [{ type: "text", text: classifyError(err, flairUrl) }], isError: true };
94
- }
52
+ import { registerStdioTools } from "./adapter-tools.js";
53
+ export { classifyError } from "./errors.js";
95
54
  // ─── Entry point ──────────────────────────────────────────────────────────────
96
55
  //
97
56
  // runMcp() is the real entry point. It is exported so the CommonJS preflight
@@ -220,440 +179,19 @@ export async function runMcp() {
220
179
  }
221
180
  // ─── MCP Server ──────────────────────────────────────────────────────────────
222
181
  const server = new McpServer(serverInfo());
223
- // ─── Tools ───────────────────────────────────────────────────────────────────
224
- server.tool("memory_search", "Search memories by meaning. Understands temporal queries like 'what happened today'.", {
225
- query: z.string().describe("Search query — natural language, semantic matching"),
226
- limit: z.coerce.number().optional().default(5).describe("Max results (default 5)"),
227
- }, async ({ query, limit }) => {
228
- heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
229
- try {
230
- const results = await flair.memory.search(query, { limit });
231
- if (results.length === 0) {
232
- return { content: [{ type: "text", text: "No relevant memories found." }] };
233
- }
234
- const text = results
235
- .map((r, i) => {
236
- const date = r.createdAt ? r.createdAt.slice(0, 10) : "";
237
- const idStr = r.id ? `id:${r.id}` : "";
238
- const meta = [date, r.type, idStr].filter(Boolean).join(", ");
239
- return `${i + 1}. ${r.content}${meta ? ` (${meta})` : ""}`;
240
- })
241
- .join("\n");
242
- return { content: [{ type: "text", text: withCiteNudge(text) }] };
243
- }
244
- catch (err) {
245
- return errorResult(err, flair.url);
246
- }
247
- });
248
- server.tool("memory_store", "Save information to persistent memory. Use for lessons, decisions, preferences, facts.", {
249
- content: z.string().describe("What to remember"),
250
- type: z.enum(["session", "lesson", "decision", "preference", "fact", "goal"]).optional().default("session"),
251
- durability: z.enum(["permanent", "persistent", "standard", "ephemeral"]).optional().default("standard")
252
- .describe("permanent — inviolable facts, identity, explicit never-forget (e.g., 'my name is Nathan')\n" +
253
- "persistent — key decisions and lessons to recall weeks later (e.g., 'PR review process')\n" +
254
- "standard — default working memory, recent context (e.g., 'discussed auth flow today')\n" +
255
- "ephemeral — scratch state, auto-expires 72h (e.g., 'currently debugging issue #42')"),
256
- tags: z.array(z.string()).optional().describe("Array of tag strings"),
257
- visibility: z.enum(["private", "shared"]).optional().describe("Writer-controlled sharing intent (omit to use the server's durability-keyed default: " +
258
- "permanent/persistent -> shared, standard/ephemeral -> private). " +
259
- "private -- never visible to another agent, even one with a memory grant. " +
260
- "shared -- visible to the owner and any agent holding a read/search grant."),
261
- usedMemoryIds: z.array(z.string()).optional().describe("IDs of memories that informed this write (citation-on-write). Credited via the same " +
262
- "deduped usage ledger as record_usage. Optional."),
263
- }, async ({ content, type, durability, tags, visibility, usedMemoryIds }) => {
264
- heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
265
- try {
266
- const result = await flair.memory.write(content, {
267
- type: type,
268
- durability: durability,
269
- tags,
270
- visibility: visibility,
271
- dedup: true,
272
- dedupThreshold: 0.95,
273
- usedMemoryIds: citationIds(usedMemoryIds),
274
- });
275
- // The server's conservative dedup gate NEVER suppresses a write
276
- // (memory-integrity fix, flair#526) — `result.deduplicated` is a
277
- // collision SIGNAL, not a "was this dropped" flag. The new content at
278
- // `result.id` is ALWAYS written; when flagged, `result.matchedId` names
279
- // the similar existing memory (see result.matchConfidence for the
280
- // cosine/lexical scores). Emit both prose AND structuredContent so
281
- // callers can react programmatically even when LLMs compress prose
282
- // imprecisely. (Historical note: this tool used to treat a dedup hit as
283
- // "new content was NOT written" — that WAS the flair#449/#526 silent
284
- // data-loss bug. The gate is server-side now and never suppresses.)
285
- const deduplicated = result.deduplicated === true;
286
- const matchedId = result.matchedId;
287
- const effectiveVisibility = result.visibility;
288
- const preview = content.length > 120 ? content.slice(0, 120) + "..." : content;
289
- const tagStr = tags && tags.length > 0 ? tags.join(", ") : "none";
290
- const lines = [
291
- `Memory stored (id: ${result.id})`,
292
- `Preview: ${preview}`,
293
- `Size: ${content.length} chars`,
294
- `Tags: ${tagStr}`,
295
- `Type: ${type}, Durability: ${durability}, Visibility: ${effectiveVisibility ?? "(server default)"}`,
296
- ];
297
- if (deduplicated && matchedId) {
298
- lines.push("", `Note: similar to existing memory id=${matchedId} — both are kept. ` +
299
- `If this was meant to UPDATE that memory rather than add a new one, use memory_update instead.`);
300
- }
301
- return {
302
- content: [{ type: "text", text: lines.join("\n") }],
303
- structuredContent: { deduplicated, id: result.id, written: true, ...(deduplicated ? { matchedId } : {}) },
304
- };
305
- }
306
- catch (err) {
307
- return errorResult(err, flair.url);
308
- }
309
- });
310
- server.tool("memory_update", "Update an existing memory by ID. Dedup-bypassed — this is an intentional overwrite/version, not an ambiguous new write. " +
311
- "Default: overwrites the same id in place. Pass preserveHistory=true to instead write a new version linked via " +
312
- "`supersedes`, closing the old one's validity window (requires owning the memory, or a write grant if it's another agent's).", {
313
- id: z.string().describe("ID of the memory to update"),
314
- content: z.string().describe("New content"),
315
- preserveHistory: z.coerce.boolean().optional().default(false)
316
- .describe("Write a new supersedes-linked version instead of overwriting in place (default false)"),
317
- usedMemoryIds: z.array(z.string()).optional().describe("IDs of memories that informed this update (citation-on-write). Credited via the same " +
318
- "deduped usage ledger as record_usage. Optional."),
319
- }, async ({ id, content, preserveHistory, usedMemoryIds }) => {
320
- heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
321
- try {
322
- const result = await flair.memory.update(id, content, {
323
- preserveHistory,
324
- usedMemoryIds: citationIds(usedMemoryIds),
325
- });
326
- const text = preserveHistory
327
- ? `Memory updated: new version stored (id: ${result.id}), supersedes ${id}.`
328
- : `Memory updated (id: ${id}).`;
329
- return {
330
- content: [{ type: "text", text }],
331
- structuredContent: { id: result.id, supersedes: preserveHistory ? id : undefined, written: true },
332
- };
333
- }
334
- catch (err) {
335
- return errorResult(err, flair.url);
336
- }
337
- });
338
- server.tool("memory_get", "Retrieve a specific memory by ID.", {
339
- id: z.string().describe("Memory ID"),
340
- }, async ({ id }) => {
341
- heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
342
- try {
343
- const mem = await flair.memory.get(id);
344
- if (!mem)
345
- return { content: [{ type: "text", text: `Memory ${id} not found.` }] };
346
- return { content: [{ type: "text", text: `${mem.content}\n\n(type: ${mem.type}, durability: ${mem.durability}, created: ${mem.createdAt})` }] };
347
- }
348
- catch (err) {
349
- return errorResult(err, flair.url);
350
- }
351
- });
352
- server.tool("memory_delete", "Delete a memory by ID.", {
353
- id: z.string().describe("Memory ID to delete"),
354
- }, async ({ id }) => {
355
- heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
356
- try {
357
- await flair.memory.delete(id);
358
- return { content: [{ type: "text", text: `Memory ${id} deleted.` }] };
359
- }
360
- catch (err) {
361
- return errorResult(err, flair.url);
362
- }
363
- });
364
- server.tool("relationship_store", "Record that <subject> <predicate> <object> — an explicit entity-to-entity relationship triple " +
365
- "(e.g. 'nathan manages flair', 'flint reviews cli'), distinct from a free-text memory. " +
366
- "ASSERT/UPSERT semantics: writing the SAME triple again (same subject/predicate/object) updates the " +
367
- "existing row in place (confidence/validTo/source refresh) rather than creating a duplicate — safe to " +
368
- "re-assert. Predicate is free text (no fixed enum) but prefer a small, consistent vocabulary so the graph " +
369
- "stays queryable: manages, works_on, reviews, depends_on, replaces, owns, reports_to, advises. " +
370
- "TO CONTRADICT a prior relationship: (a) re-asserting the identical triple just updates it — fine. " +
371
- "(b) changing validTo on the SAME subject/predicate/object overwrites the old validTo (the graph tracks " +
372
- "current state, not full history). (c) changing the PREDICATE (e.g. 'nathan manages flair' -> 'nathan " +
373
- "advises flair') creates a SEPARATE relationship — it does NOT automatically close the old one. Close it " +
374
- "yourself first: re-assert the OLD triple with a validTo set to now (or call relationship's delete), THEN " +
375
- "store the new one.", {
376
- subject: z.string().describe("Source entity — a person, project, or service (e.g. 'nathan')"),
377
- predicate: z.string().describe("Relationship type, free text. Recommended vocabulary: manages, works_on, reviews, depends_on, " +
378
- "replaces, owns, reports_to, advises — consistency helps recall, but any short verb phrase works."),
379
- object: z.string().describe("Target entity — a person, project, or service (e.g. 'flair')"),
380
- confidence: z.coerce.number().optional().describe("0.0-1.0, how certain (default 1.0 = explicitly stated)"),
381
- validFrom: z.string().optional().describe("ISO timestamp this relationship became true (default: now)"),
382
- validTo: z.string().optional().describe("ISO timestamp this relationship ended. Leave unset for an active relationship; set it (via a re-assert " +
383
- "of this SAME subject/predicate/object) to close out a relationship you're contradicting with a new predicate."),
384
- source: z.string().optional().describe("Where this was learned from (a memory ID, conversation, etc.)"),
385
- }, async ({ subject, predicate, object, confidence, validFrom, validTo, source }) => {
386
- heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
387
- try {
388
- const result = await flair.relationship.write({ subject, predicate, object, confidence, validFrom, validTo, source });
389
- const confStr = confidence !== undefined ? ` (confidence: ${confidence})` : "";
390
- return {
391
- content: [{ type: "text", text: `Relationship recorded: ${subject} → ${predicate} → ${object}${confStr} (id: ${result.id})` }],
392
- structuredContent: { id: result.id, subject, predicate, object, written: true },
393
- };
394
- }
395
- catch (err) {
396
- return errorResult(err, flair.url);
397
- }
398
- });
399
- server.tool("bootstrap", "Get session context: soul + memories + predicted context. Run at session start. Pass subjects for predictive loading.", {
400
- maxTokens: z.coerce.number().optional().default(4000).describe("Max tokens in output"),
401
- currentTask: z.string().optional().describe("Current task description — enables semantic search for relevant memories"),
402
- channel: z.string().optional().describe("Channel name (discord, tps-mail, claude-code) — shapes context prediction"),
403
- surface: z.string().optional().describe("Surface name (tps-build, tps-review, cli-session) — narrows prediction"),
404
- subjects: z.array(z.string()).optional().describe("Entity names to preload context for (e.g., ['flair', 'auth'])"),
405
- }, async ({ maxTokens, currentTask, channel, surface, subjects }) => {
406
- // auto-presence (flair#598) — SESSION START. bootstrap() already receives
407
- // exactly the payload Presence wants: currentTask is what the agent is
408
- // about to work on, channel/surface are what deriveActivity() uses to
409
- // pick something more specific than the "coding" default. Routed through
410
- // the SAME rate-limited heartbeat() as every other tool, so calling
411
- // bootstrap twice in quick succession (session resume/compact) doesn't
412
- // double-send — it's still "sets presence once" per session in practice.
413
- if (currentTask)
414
- lastKnownTask = currentTask;
415
- heartbeat(deriveActivity({ channel, surface }));
416
- try {
417
- const result = await flair.bootstrap({ maxTokens, currentTask, channel, surface, subjects });
418
- if (!result.context) {
419
- return { content: [{ type: "text", text: "No context available." }] };
420
- }
421
- return { content: [{ type: "text", text: withCiteNudge(result.context) }] };
422
- }
423
- catch (err) {
424
- return errorResult(err, flair.url);
425
- }
426
- });
427
- server.tool("soul_set", "Set a personality or project context entry. Included in every bootstrap.", {
428
- key: z.string().describe("Entry key (e.g., 'role', 'standards', 'project')"),
429
- value: z.string().describe("Entry value — personality trait, project context, coding standards, etc."),
430
- }, async ({ key, value }) => {
431
- heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
432
- try {
433
- await flair.soul.set(key, value);
434
- return { content: [{ type: "text", text: `Soul entry '${key}' set.` }] };
435
- }
436
- catch (err) {
437
- return errorResult(err, flair.url);
438
- }
439
- });
440
- server.tool("soul_get", "Get a personality or project context entry.", {
441
- key: z.string().describe("Entry key"),
442
- }, async ({ key }) => {
443
- heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
444
- try {
445
- const entry = await flair.soul.get(key);
446
- if (!entry)
447
- return { content: [{ type: "text", text: `No soul entry for '${key}'.` }] };
448
- return { content: [{ type: "text", text: entry.value }] };
449
- }
450
- catch (err) {
451
- return errorResult(err, flair.url);
452
- }
453
- });
454
- // ─── Coordination write surface ──────────────────────────────────────────────
182
+ // ─── Tools (derived from the vendored tool-descriptors module) ────────────
455
183
  //
456
- // flair_workspace_set + flair_orgevent let an agent write the Office Space
457
- // coordination layer without hand-rolling signed HTTP. Both go through
458
- // flair.request(), which signs with the agent's Ed25519 keyso identity
459
- // (WorkspaceState.agentId / OrgEvent.authorId) is taken from the SIGNATURE on
460
- // the server side, NEVER the body. We deliberately do NOT send agentId/authorId
461
- // in the body; the handlers attribute the write to the authenticated agent, so
462
- // an agent can only write AS itself (no forging).
463
- server.tool("flair_workspace_set", "Set your agent's current workspace state in the Office Space coordination layer (ref/branch, phase, task). Attributed to you from your signed identity — you can only write your own state.", {
464
- ref: z.string().describe("Workspace ref — branch, worktree, or task ref"),
465
- label: z.string().optional().describe("Human-readable label for this workspace"),
466
- provider: z.string().optional().default("mcp").describe("Provider/runtime (e.g. claude-code, openclaw)"),
467
- task: z.string().optional().describe("Task/issue id this workspace is attached to"),
468
- phase: z.string().optional().describe("Current phase (e.g. design, implement, review)"),
469
- summary: z.string().optional().describe("Short summary of current workspace state"),
470
- }, async ({ ref, label, provider, task, phase, summary }) => {
471
- heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
472
- try {
473
- // No agentId in body — the server attributes from the signed identity.
474
- const body = {
475
- id: `${agentId}:${ref}`,
476
- ref,
477
- provider: provider ?? "mcp",
478
- timestamp: new Date().toISOString(),
479
- };
480
- if (label)
481
- body.label = label;
184
+ // The advertised set is STDIO_TOOL_DESCRIPTORS. Handlers bind each
185
+ // descriptor to a FlairClient HTTP call. A new shared descriptor appears
186
+ // here for free once a FlairClient binding existsno per-tool literals.
187
+ registerStdioTools(server, {
188
+ flair,
189
+ agentId,
190
+ heartbeat,
191
+ rememberTask: (task) => {
482
192
  if (task)
483
- body.taskId = task;
484
- if (phase)
485
- body.phase = phase;
486
- if (summary)
487
- body.summary = summary;
488
- await flair.request("POST", "/WorkspaceState", body);
489
- return { content: [{ type: "text", text: `Workspace state set: ref=${ref}${phase ? `, phase=${phase}` : ""} (attributed to ${agentId}).` }] };
490
- }
491
- catch (err) {
492
- return errorResult(err, flair.url);
493
- }
494
- });
495
- server.tool("flair_orgevent", "Publish an org-wide coordination event (claim/release/status) to the Office Space. Attributed to you from your signed identity — you cannot publish as another agent.", {
496
- kind: z.string().describe("Event kind (e.g. coord.claim, coord.release, status)"),
497
- summary: z.string().describe("Short summary of the event"),
498
- detail: z.string().optional().describe("Longer detail payload"),
499
- scope: z.string().optional().describe("Scope of the event (e.g. an agent id, repo, or 'org')"),
500
- targets: z.array(z.string()).optional().describe("Recipient agent ids"),
501
- }, async ({ kind, summary, detail, scope, targets }) => {
502
- heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
503
- try {
504
- // No authorId in body — the server attributes from the signed identity.
505
- const body = { kind, summary };
506
- if (detail)
507
- body.detail = detail;
508
- if (scope)
509
- body.scope = scope;
510
- if (targets && targets.length > 0)
511
- body.targetIds = targets;
512
- const result = await flair.request("POST", "/OrgEvent", body);
513
- const targetStr = targets && targets.length > 0 ? ` → ${targets.join(", ")}` : "";
514
- const idStr = result?.id ? ` (id: ${result.id})` : "";
515
- return { content: [{ type: "text", text: `OrgEvent published: kind=${kind}${targetStr} (attributed to ${agentId})${idStr}.` }] };
516
- }
517
- catch (err) {
518
- return errorResult(err, flair.url);
519
- }
520
- });
521
- // ─── Usage feedback (flair#1147) ─────────────────────────────────────────────
522
- //
523
- // POST /RecordUsage already existed; native /mcp already wrapped it. The
524
- // stdio package did not, so a Claude Code / Cursor client could not close
525
- // the usageCount loop. Identity is taken from the signed request — the body
526
- // carries only memory id(s) + optional attribution, never agentId.
527
- server.tool("record_usage", "Report that one or more memories were actually USED — cited or relied on to ground an answer or decision. " +
528
- "Distinct from search (surfacing a memory is not usage). Dedup'd (you can only count once per memory) and rate-limited. " +
529
- RECORD_USAGE_ID_MERGE_CONTRACT, {
530
- memoryId: z.string().optional().describe("A single memory id that was used. Merged with memoryIds when both are supplied — not dropped."),
531
- memoryIds: z.array(z.string()).optional().describe("IDs of the memories that were used (max 20 per call). Merged with memoryId when both are supplied."),
532
- attribution: z.string().optional().describe("Optional one-line note on how it was used (opaque — stored for audit only)"),
533
- }, async ({ memoryId, memoryIds, attribution }) => {
534
- heartbeat(); // auto-presence (flair#598) — fire-and-forget, rate-limited
535
- try {
536
- const body = buildRecordUsageBody({ memoryId, memoryIds, attribution });
537
- if (!body) {
538
- return {
539
- content: [{ type: "text", text: "record_usage requires memoryId or memoryIds." }],
540
- isError: true,
541
- };
542
- }
543
- const result = await flair.request("POST", "/RecordUsage", body);
544
- const text = result?.recorded === true ? "Usage recorded." : "Usage request accepted.";
545
- return {
546
- content: [{ type: "text", text }],
547
- structuredContent: { recorded: result?.recorded === true },
548
- };
549
- }
550
- catch (err) {
551
- return errorResult(err, flair.url);
552
- }
553
- });
554
- // ─── Skills as memory (flair#1575 / #1542 / #1546) ──────────────────────────
555
- //
556
- // Native `/mcp` already ships skill_store / skill_search / skill_get in the
557
- // TOOLS registry. This stdio adapter is the surface Claude Code and Cursor
558
- // actually use, and it never wired them — 0.52.0's headline was unreachable.
559
- // Same pattern as the other tools: shape the HTTP call via FlairClient,
560
- // heartbeat, format the result. Write/recall/scope policy stays server-side.
561
- server.tool("skill_store", "Write a skill (a reusable capability/procedure) as a skill-tagged memory. " +
562
- "The `trigger` text is what the skill embeds from (the recall signal — 'when to use this'), " +
563
- "and `content` is the full procedure. Skills are forced durability=persistent and are " +
564
- "SkillScan-gated before the embed (a dangerous shell/network payload is rejected).", {
565
- content: z.string().describe("The full procedure (markdown body of the SKILL.md)"),
566
- trigger: z.string().optional().describe("The 'when to use' text — the recall signal the skill embeds from"),
567
- name: z.string().optional().describe("Skill name (SKILL.md frontmatter; stored in metadata)"),
568
- description: z.string().optional().describe("Skill description (SKILL.md frontmatter; stored in metadata)"),
569
- tags: z.array(z.string()).optional().describe("Additional tags (the 'skill' tag is added automatically)"),
570
- }, async ({ content, trigger, name, description, tags }) => {
571
- heartbeat();
572
- try {
573
- const { id, body } = buildSkillStoreBody({
574
- agentId: flair.agentId,
575
- content,
576
- trigger,
577
- name,
578
- description,
579
- tags,
580
- claimedClient: flair.claimedClient,
581
- });
582
- const result = await flair.request("PUT", `/Memory/${id}`, body);
583
- const writtenId = typeof result?.id === "string" && result.id.length > 0 ? result.id : id;
584
- const preview = content.length > 120 ? content.slice(0, 120) + "..." : content;
585
- const lines = [
586
- `Skill stored (id: ${writtenId})`,
587
- `Preview: ${preview}`,
588
- name ? `Name: ${name}` : undefined,
589
- trigger ? `Trigger: ${trigger}` : undefined,
590
- ].filter((line) => line != null);
591
- return {
592
- content: [{ type: "text", text: lines.join("\n") }],
593
- structuredContent: { id: writtenId, written: true },
594
- };
595
- }
596
- catch (err) {
597
- return errorResult(err, flair.url);
598
- }
599
- });
600
- server.tool("skill_search", "Find skills (reusable capabilities/procedures) that apply to a task. " +
601
- "Ranks skill-tagged memories by their `trigger` ('when to use') against your task text. " +
602
- "Returns a lightweight CATALOG — id, name, trigger, description, tags, agentId — NOT the full " +
603
- "procedure (fetch that with skill_get). Scoped to your own + shared skills; another agent's " +
604
- "private skill is never returned.", {
605
- task: z.string().describe("The task/context to match skills against — natural language; ranked against each skill's trigger"),
606
- limit: z.coerce.number().optional().default(5).describe("Max skills to return (default 5)"),
607
- }, async ({ task, limit }) => {
608
- heartbeat();
609
- try {
610
- const raw = await flair.request("POST", "/SemanticSearch", buildSkillSearchBody({ task, limit }));
611
- const projected = projectSkillSearchResponse(raw);
612
- if (!projected || typeof projected !== "object" || !Array.isArray(projected.results)) {
613
- return { content: [{ type: "text", text: "No matching skills found." }] };
614
- }
615
- const results = projected.results;
616
- return {
617
- content: [{ type: "text", text: formatSkillCatalog(results) }],
618
- structuredContent: { results },
619
- };
620
- }
621
- catch (err) {
622
- return errorResult(err, flair.url);
623
- }
624
- });
625
- server.tool("skill_get", "Retrieve a full skill by ID — the complete procedure (`content`) plus trigger and metadata. " +
626
- "The disclosure step after skill_search's catalog. Read-scoped: you can only get your own or a " +
627
- "shared skill, never another agent's private skill. A non-skill id returns not-found.", {
628
- id: z.string().describe("Skill (memory) ID"),
629
- includeEmbedding: z.coerce.boolean().optional().default(false)
630
- .describe("Include the raw embedding vector (large, rarely useful). Default false."),
631
- }, async ({ id, includeEmbedding }) => {
632
- heartbeat();
633
- try {
634
- const mem = await flair.memory.get(id);
635
- if (!mem || !isSkillRecord(mem)) {
636
- return { content: [{ type: "text", text: `Skill ${id} not found.` }] };
637
- }
638
- const record = includeEmbedding
639
- ? mem
640
- : stripInternalMemoryFields(mem);
641
- const trigger = typeof record.trigger === "string" && record.trigger.length > 0
642
- ? record.trigger
643
- : "";
644
- const text = [
645
- record.content,
646
- "",
647
- `(id: ${record.id}${trigger ? `, trigger: ${trigger}` : ""}, tags: ${Array.isArray(record.tags) ? record.tags.join(", ") : "skill"}, created: ${record.createdAt ?? ""})`,
648
- ].join("\n");
649
- return {
650
- content: [{ type: "text", text }],
651
- structuredContent: record,
652
- };
653
- }
654
- catch (err) {
655
- return errorResult(err, flair.url);
656
- }
193
+ lastKnownTask = task;
194
+ },
657
195
  });
658
196
  // ─── Start ───────────────────────────────────────────────────────────────────
659
197
  const transport = new StdioServerTransport();
@@ -0,0 +1,10 @@
1
+ /**
2
+ * JSON Schema → Zod raw shape for MCP SDK `server.tool()` (flair#1580).
3
+ *
4
+ * Lives in the adapter, not the descriptor module: Zod is an SDK concern.
5
+ * Number/boolean use `z.coerce` so MCP clients that send stringified scalars
6
+ * keep working (the previous hand-wired Zod schemas did the same).
7
+ */
8
+ import { type ZodTypeAny } from "zod";
9
+ import type { JsonSchemaObject } from "./tool-descriptors/index.js";
10
+ export declare function jsonSchemaToZodShape(schema: JsonSchemaObject): Record<string, ZodTypeAny>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * JSON Schema → Zod raw shape for MCP SDK `server.tool()` (flair#1580).
3
+ *
4
+ * Lives in the adapter, not the descriptor module: Zod is an SDK concern.
5
+ * Number/boolean use `z.coerce` so MCP clients that send stringified scalars
6
+ * keep working (the previous hand-wired Zod schemas did the same).
7
+ */
8
+ import { z } from "zod";
9
+ export function jsonSchemaToZodShape(schema) {
10
+ const required = new Set(schema.required ?? []);
11
+ const shape = {};
12
+ for (const [key, prop] of Object.entries(schema.properties ?? {})) {
13
+ let t = propToZod(prop);
14
+ if (prop.description)
15
+ t = t.describe(prop.description);
16
+ if (!required.has(key))
17
+ t = t.optional();
18
+ if (prop.default !== undefined)
19
+ t = t.default(prop.default);
20
+ shape[key] = t;
21
+ }
22
+ return shape;
23
+ }
24
+ function propToZod(prop) {
25
+ if (Array.isArray(prop.enum) && prop.enum.length > 0) {
26
+ const [first, ...rest] = prop.enum;
27
+ return z.enum([first, ...rest]);
28
+ }
29
+ switch (prop.type) {
30
+ case "string":
31
+ return z.string();
32
+ case "number":
33
+ return z.coerce.number();
34
+ case "boolean":
35
+ return z.coerce.boolean();
36
+ case "array":
37
+ if (prop.items?.type === "string")
38
+ return z.array(z.string());
39
+ throw new Error(`unsupported array items: ${JSON.stringify(prop.items)}`);
40
+ default:
41
+ throw new Error(`unsupported json schema type: ${JSON.stringify(prop.type)}`);
42
+ }
43
+ }
package/dist/skills.d.ts CHANGED
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * Progressive disclosure matches the native contract: skill_search returns
12
12
  * catalog cards (never `content` / embedding); skill_get is the disclosure
13
- * step for the full procedure.
13
+ * step for the full procedure. skill_get always strips embedding /
14
+ * embeddingModel (flair#1579) — there is no includeEmbedding opt-in.
14
15
  */
15
16
  /** The tag that marks a Memory as a skill (resources/skill-write.ts). */
16
17
  export declare const SKILL_TAG = "skill";
package/dist/skills.js CHANGED
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * Progressive disclosure matches the native contract: skill_search returns
12
12
  * catalog cards (never `content` / embedding); skill_get is the disclosure
13
- * step for the full procedure.
13
+ * step for the full procedure. skill_get always strips embedding /
14
+ * embeddingModel (flair#1579) — there is no includeEmbedding opt-in.
14
15
  */
15
16
  /** The tag that marks a Memory as a skill (resources/skill-write.ts). */
16
17
  export const SKILL_TAG = "skill";