@tpsdev-ai/flair-mcp 0.55.2 → 0.57.0

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
@@ -23,7 +23,7 @@ cat > .mcp.json << 'EOF'
23
23
  EOF
24
24
  ```
25
25
 
26
- `npx -y @tpsdev-ai/flair-mcp` fetches and runs the server on demand — no global install needed. (`flair init` wires this for you automatically; see below.)
26
+ `npx -y @tpsdev-ai/flair-mcp` fetches and runs the server on demand — no global install needed. (`flair init --agent <id>` attempts to wire detected clients when wiring is enabled; see below.)
27
27
 
28
28
  ### Prerequisites
29
29
 
@@ -48,7 +48,7 @@ Once configured, Claude Code (or any MCP client) gets these tools:
48
48
  | `skill_search` | Find skills that apply to a task. Returns a catalog, not the procedure. |
49
49
  | `skill_get` | Retrieve the full skill by ID (disclosure after `skill_search`). |
50
50
  | `bootstrap` | Cold-start context — soul + recent memories in one call. |
51
- | `soul_set` | Set personality or project context (included in every bootstrap). |
51
+ | `soul_set` | Soul writes require verified administrator Basic credentials; Ed25519 agent requests are refused. Operators should use the REST API or CLI. |
52
52
  | `soul_get` | Get a personality or project context entry. |
53
53
  | `record_usage` | Report that recalled memories were actually used (drives `usageCount`). |
54
54
 
@@ -68,7 +68,7 @@ Once configured, Claude Code (or any MCP client) gets these tools:
68
68
  Claude Code ↔ stdio ↔ flair-mcp ↔ HTTP ↔ Flair (Harper)
69
69
  ```
70
70
 
71
- The MCP server is a thin wrapper around `@tpsdev-ai/flair-client`. All memory is stored in your local Flair instance with Ed25519 authentication. Nothing leaves your machine unless you point `FLAIR_URL` at a remote server.
71
+ The MCP server is a thin wrapper around `@tpsdev-ai/flair-client`. All memory is stored in the Flair instance selected by `FLAIR_URL` (defaulting to localhost). Requests are signed with the agent's Ed25519 key whenever one resolves. Only when no key resolves, and both `FLAIR_ADMIN_USER` and `FLAIR_ADMIN_PASSWORD` are set, does the client send admin Basic auth; a key that cannot be parsed is an error, and a rejected signature is not retried with Basic. The MCP client connects to FLAIR_URL; a paired local instance may separately federate eligible memories.
72
72
 
73
73
  ## Remote Flair
74
74
 
@@ -82,14 +82,14 @@ Point to a remote Flair instance:
82
82
  "args": ["-y", "@tpsdev-ai/flair-mcp"],
83
83
  "env": {
84
84
  "FLAIR_AGENT_ID": "my-project",
85
- "FLAIR_URL": "http://your-server:19926"
85
+ "FLAIR_URL": "https://your-server:19926"
86
86
  }
87
87
  }
88
88
  }
89
89
  }
90
90
  ```
91
91
 
92
- Copy your key from the server: `scp server:~/.flair/keys/my-project.key ~/.flair/keys/`
92
+ The client REFUSES to send admin Basic credentials over plain HTTP to a non-loopback host (the credentials would travel in a request header): use an HTTPS `FLAIR_URL`, or an Ed25519 key, for a remote instance. Copy your key from the server: `scp server:~/.flair/keys/my-project.key ~/.flair/keys/`
93
93
 
94
94
  ## License
95
95
 
@@ -6,6 +6,7 @@
6
6
  * descriptor with no handler (or a handler with no descriptor) fails at
7
7
  * registration, so the surfaces cannot drift by omission.
8
8
  */
9
+ import { encodeRecordId } from "./record-id-path.js";
9
10
  import { STDIO_TOOL_DESCRIPTORS, toStdioMcpToolDef, } from "./tool-descriptors/index.js";
10
11
  import { buildCatchupRequest, summarizeCatchup } from "./catchup.js";
11
12
  import { classifyError } from "./errors.js";
@@ -265,7 +266,7 @@ const skill_store = async ({ content, trigger, name, description, tags }, { flai
265
266
  tags,
266
267
  claimedClient: flair.claimedClient,
267
268
  });
268
- const result = await flair.request("PUT", `/Memory/${id}`, body);
269
+ const result = await flair.request("PUT", `/Memory/${encodeRecordId(id)}`, body);
269
270
  const writtenId = typeof result?.id === "string" && result.id.length > 0 ? result.id : id;
270
271
  const preview = content.length > 120 ? content.slice(0, 120) + "..." : content;
271
272
  const lines = [
@@ -41,6 +41,13 @@ export interface CaptureDeps {
41
41
  sessionDir?: string;
42
42
  env?: Record<string, string | undefined>;
43
43
  now?: () => Date;
44
+ /** Test seam (flair#1970): the row whose `id` becomes the `/Memory/<id>` path
45
+ * segment. Its id is URL-safe by construction, so a test drives the REAL
46
+ * request path with a dot-segment id through this override and checks that
47
+ * no request is sent when the id is refused. Production never sets it. */
48
+ buildRow?: (agentId: string, state: unknown, plan: unknown, now: Date) => {
49
+ id: string;
50
+ };
44
51
  /** Debug-level warn sink (default: one stderr line). Never stdout. */
45
52
  warn?: (message: string) => void;
46
53
  }
@@ -34,6 +34,7 @@
34
34
  * FLAIR_HOOK_PROBE (probe mode: exit immediately, no stdin read, no writes)
35
35
  */
36
36
  import { isProbeMode, readEnvOrUnset, stripInterpolationLiteralsFromEnv } from "./env-guard.js";
37
+ import { memoryPutPath } from "./record-id-path.js";
37
38
  import { buildJournalRow, bumpSeq, isSafeFileId, planCapture, resolveContinuityTimeoutMs, resolveSessionDir, } from "./continuity.js";
38
39
  /** Read all of stdin. Resolves on EOF, with a short fallback for manual runs
39
40
  * where nothing is piped (so it never hangs). */
@@ -121,11 +122,11 @@ export async function runCapture(rawInput, deps = {}) {
121
122
  const state = bumpSeq(sessionDir, agentId, harnessSessionId, now());
122
123
  if (!state)
123
124
  return { wrote: false, reason: "no-state" };
124
- const row = buildJournalRow(agentId, state, plan, now());
125
+ const row = (deps.buildRow ?? buildJournalRow)(agentId, state, plan, now());
125
126
  const makeClient = deps.makeClient ?? defaultClientFactory;
126
127
  try {
127
128
  const client = await makeClient(agentId);
128
- await withTimeout(Promise.resolve(client.request("PUT", `/Memory/${row.id}`, row)), resolveContinuityTimeoutMs(env));
129
+ await withTimeout(Promise.resolve(client.request("PUT", memoryPutPath(row.id), row)), resolveContinuityTimeoutMs(env));
129
130
  return { wrote: true, reason: "written" };
130
131
  }
131
132
  catch (err) {
@@ -0,0 +1,20 @@
1
+ /**
2
+ * record-id-path.ts — the one rule for a record id used as the single dynamic
3
+ * path segment of a `/Memory/<id>` request built inside flair-mcp (flair#1970).
4
+ *
5
+ * Self-contained on purpose: this module must load and typecheck WITHOUT
6
+ * @tpsdev-ai/flair-client's built dist present (the root `bun test` and strict
7
+ * test-suite typecheck lanes run before the client is built), so it does not
8
+ * import the client's own `encodeRecordId` — it states the same rule here.
9
+ */
10
+ /**
11
+ * Percent-encode an id so it reaches the server as ONE path segment that
12
+ * decodes back to the id, addressing exactly that record. REFUSES an id that is
13
+ * exactly `.` or `..`: percent-encoding leaves those unchanged and URL
14
+ * normalization collapses `/Memory/.` to `/Memory/` and `/Memory/..` to `/`, so
15
+ * the sent path would not be the id (nor the signed path). Such an id cannot
16
+ * address its record, so it is an error, not a request.
17
+ */
18
+ export declare function encodeRecordId(id: string): string;
19
+ /** The PUT path for a row: its id as ONE percent-encoded path segment. */
20
+ export declare function memoryPutPath(id: string): string;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * record-id-path.ts — the one rule for a record id used as the single dynamic
3
+ * path segment of a `/Memory/<id>` request built inside flair-mcp (flair#1970).
4
+ *
5
+ * Self-contained on purpose: this module must load and typecheck WITHOUT
6
+ * @tpsdev-ai/flair-client's built dist present (the root `bun test` and strict
7
+ * test-suite typecheck lanes run before the client is built), so it does not
8
+ * import the client's own `encodeRecordId` — it states the same rule here.
9
+ */
10
+ /**
11
+ * Percent-encode an id so it reaches the server as ONE path segment that
12
+ * decodes back to the id, addressing exactly that record. REFUSES an id that is
13
+ * exactly `.` or `..`: percent-encoding leaves those unchanged and URL
14
+ * normalization collapses `/Memory/.` to `/Memory/` and `/Memory/..` to `/`, so
15
+ * the sent path would not be the id (nor the signed path). Such an id cannot
16
+ * address its record, so it is an error, not a request.
17
+ */
18
+ export function encodeRecordId(id) {
19
+ if (id === "." || id === "..") {
20
+ throw new Error(`record id ${JSON.stringify(id)} is a URL path dot-segment ("." or ".."); ` +
21
+ `it cannot be addressed as one path segment of /Memory/<id>. Use a different id.`);
22
+ }
23
+ return encodeURIComponent(id);
24
+ }
25
+ /** The PUT path for a row: its id as ONE percent-encoded path segment. */
26
+ export function memoryPutPath(id) {
27
+ return `/Memory/${encodeRecordId(id)}`;
28
+ }
@@ -17,11 +17,14 @@
17
17
  *
18
18
  * NO-OP-ON-ANY-FAILURE GUARANTEE
19
19
  * ------------------------------
20
- * This hook can never block or break Claude Code startup. Every failure mode —
21
- * missing FLAIR_AGENT_ID, malformed stdin, Flair unreachable, auth error, a
22
- * hung daemon, an unexpected throw — degrades to printing `{}` (an empty,
23
- * inert hook output) and exiting 0. It never throws, never writes to stderr in
24
- * a way that surfaces to the user, and never exits non-zero.
20
+ * This hook can never block or break Claude Code startup. Every failure mode
21
+ * (missing FLAIR_AGENT_ID, malformed stdin, Flair unreachable, auth error, a
22
+ * hung daemon, an unexpected throw) exits 0. Malformed stdin is treated as
23
+ * empty input and can still yield bootstrap context. A failed bootstrap yields
24
+ * a continuity resume hint only when the separate lookup finds eligible prior
25
+ * entries; otherwise stdout is `{}`. The hook attempts one stderr diagnostic
26
+ * when bootstrap fails (flair#1943). The Codex command retains stderr and the
27
+ * Claude Code command discards it; delivery depends on stderr being writable.
25
28
  *
26
29
  * A hard timeout (FLAIR_HOOK_TIMEOUT_MS, default 8s) wraps the bootstrap call
27
30
  * so a stalled Flair daemon can't hang session startup; on timeout we no-op.
@@ -96,6 +99,23 @@ interface BootstrapClient extends Partial<PresencePoster> {
96
99
  * sharing it; re-exported here unchanged so existing importers keep working.
97
100
  */
98
101
  export { isProbeMode };
102
+ /** The hook's OWN bootstrap-timer rejection (flair#1943). A dedicated class so
103
+ * the classifier recognises its own timeout by IDENTITY, never by reading a
104
+ * message. */
105
+ export declare class BootstrapTimeoutError extends Error {
106
+ constructor();
107
+ }
108
+ export type BootstrapFailureKind = "auth" | "timeout" | "unreachable" | `http-${number}`;
109
+ /**
110
+ * flair#1943 — classify a bootstrap failure for the one stderr line. Reads a
111
+ * numeric HTTP status FIRST (`status`, what FlairError carries, then
112
+ * `status_code`, then `statusCode`); when a status exists the message is never
113
+ * consulted. With no status, the ONLY timeout is the hook's own bootstrap
114
+ * timer (a BootstrapTimeoutError) or an error whose name is exactly
115
+ * `TimeoutError`; everything else is `unreachable`. No kind is ever decided
116
+ * from message text. Never reads or includes credentials.
117
+ */
118
+ export declare function classifyBootstrapFailure(err: unknown): BootstrapFailureKind;
99
119
  /**
100
120
  * Core hook logic, with injectable dependencies so it can be unit-tested
101
121
  * without a live Flair daemon. Returns the exact string to print to stdout.
@@ -17,11 +17,14 @@
17
17
  *
18
18
  * NO-OP-ON-ANY-FAILURE GUARANTEE
19
19
  * ------------------------------
20
- * This hook can never block or break Claude Code startup. Every failure mode —
21
- * missing FLAIR_AGENT_ID, malformed stdin, Flair unreachable, auth error, a
22
- * hung daemon, an unexpected throw — degrades to printing `{}` (an empty,
23
- * inert hook output) and exiting 0. It never throws, never writes to stderr in
24
- * a way that surfaces to the user, and never exits non-zero.
20
+ * This hook can never block or break Claude Code startup. Every failure mode
21
+ * (missing FLAIR_AGENT_ID, malformed stdin, Flair unreachable, auth error, a
22
+ * hung daemon, an unexpected throw) exits 0. Malformed stdin is treated as
23
+ * empty input and can still yield bootstrap context. A failed bootstrap yields
24
+ * a continuity resume hint only when the separate lookup finds eligible prior
25
+ * entries; otherwise stdout is `{}`. The hook attempts one stderr diagnostic
26
+ * when bootstrap fails (flair#1943). The Codex command retains stderr and the
27
+ * Claude Code command discards it; delivery depends on stderr being writable.
25
28
  *
26
29
  * A hard timeout (FLAIR_HOOK_TIMEOUT_MS, default 8s) wraps the bootstrap call
27
30
  * so a stalled Flair daemon can't hang session startup; on timeout we no-op.
@@ -120,10 +123,19 @@ function readStdin() {
120
123
  setTimeout(() => resolve(data), 200).unref?.();
121
124
  });
122
125
  }
123
- /** Race a promise against a timeout. Rejects with a timeout error if exceeded. */
126
+ /** The hook's OWN bootstrap-timer rejection (flair#1943). A dedicated class so
127
+ * the classifier recognises its own timeout by IDENTITY, never by reading a
128
+ * message. */
129
+ export class BootstrapTimeoutError extends Error {
130
+ constructor() {
131
+ super("bootstrap timeout");
132
+ this.name = "BootstrapTimeoutError";
133
+ }
134
+ }
135
+ /** Race a promise against a timeout. Rejects with a BootstrapTimeoutError if exceeded. */
124
136
  function withTimeout(promise, ms) {
125
137
  return new Promise((resolve, reject) => {
126
- const timer = setTimeout(() => reject(new Error("bootstrap_timeout")), ms);
138
+ const timer = setTimeout(() => reject(new BootstrapTimeoutError()), ms);
127
139
  timer.unref?.();
128
140
  promise.then((value) => {
129
141
  clearTimeout(timer);
@@ -134,6 +146,60 @@ function withTimeout(promise, ms) {
134
146
  });
135
147
  });
136
148
  }
149
+ /**
150
+ * flair#1943 — classify a bootstrap failure for the one stderr line. Reads a
151
+ * numeric HTTP status FIRST (`status`, what FlairError carries, then
152
+ * `status_code`, then `statusCode`); when a status exists the message is never
153
+ * consulted. With no status, the ONLY timeout is the hook's own bootstrap
154
+ * timer (a BootstrapTimeoutError) or an error whose name is exactly
155
+ * `TimeoutError`; everything else is `unreachable`. No kind is ever decided
156
+ * from message text. Never reads or includes credentials.
157
+ */
158
+ export function classifyBootstrapFailure(err) {
159
+ const e = err;
160
+ const status = numericStatus(e);
161
+ if (status !== undefined)
162
+ return status === 401 || status === 403 ? "auth" : `http-${status}`;
163
+ if (err instanceof BootstrapTimeoutError)
164
+ return "timeout";
165
+ if (typeof e?.name === "string" && e.name === "TimeoutError")
166
+ return "timeout";
167
+ return "unreachable";
168
+ }
169
+ /** The first NUMERIC HTTP status the error carries, checked `status` →
170
+ * `status_code` → `statusCode` (flair#1943). */
171
+ function numericStatus(e) {
172
+ for (const key of ["status", "status_code", "statusCode"]) {
173
+ const v = e?.[key];
174
+ if (typeof v === "number" && Number.isFinite(v))
175
+ return v;
176
+ }
177
+ return undefined;
178
+ }
179
+ // flair#1943: one no-op 'error' listener per process, so repeated failed
180
+ // runs in one process never add listeners (and never trigger Node's
181
+ // max-listeners warning on stderr).
182
+ let stderrErrorAbsorbed = false;
183
+ /** The stderr diagnostic for a failed bootstrap. NAMES the actor, the state and
184
+ * the remedy; never contains a key, token, password or Authorization value. */
185
+ function reportBootstrapFailure(err) {
186
+ const kind = classifyBootstrapFailure(err);
187
+ const line = `flair session-start: bootstrap failed (${kind}); this session starts without bootstrap context. Next: run \`flair doctor\`, and check FLAIR_URL and this agent's key.\n`;
188
+ try {
189
+ // Best-effort (flair#1943): a failed stderr write (a closed pipe → EPIPE)
190
+ // must not change stdout or the exit code. The write may throw
191
+ // SYNCHRONOUSLY or surface later as an 'error' event on the stream; absorb
192
+ // both, so the hook still prints its payload and exits 0.
193
+ if (!stderrErrorAbsorbed) {
194
+ process.stderr.on("error", () => { });
195
+ stderrErrorAbsorbed = true;
196
+ }
197
+ process.stderr.write(line);
198
+ }
199
+ catch {
200
+ // ignore — the diagnostic is best-effort
201
+ }
202
+ }
137
203
  /** Build the SessionStart hook output JSON from a context string. */
138
204
  function hookOutput(context) {
139
205
  return JSON.stringify({
@@ -207,8 +273,14 @@ export async function runHook(rawInput, makeClient = defaultClientFactory) {
207
273
  })), resolveTimeoutMs());
208
274
  context = res && res.context ? String(res.context) : "";
209
275
  }
210
- catch {
276
+ catch (err) {
211
277
  context = ""; // flair unreachable / auth error / timeout → no bootstrap context
278
+ // flair#1943: keeping stderr open cannot reveal an error never written to
279
+ // it. Write ONE line to STDERR (never stdout — that is the hook payload),
280
+ // so a real failure stays visible instead of being swallowed. stdout and
281
+ // the exit code are unchanged (the no-op payload), so a failure never
282
+ // blocks the session.
283
+ reportBootstrapFailure(err);
212
284
  }
213
285
  const resumeHint = await resumeHintDone;
214
286
  await presenceDone;
@@ -51,7 +51,7 @@ export function descriptorNames(descriptors) {
51
51
  export const TOOL_DESCRIPTORS = [
52
52
  {
53
53
  "name": "memory_search",
54
- "description": "Search memories by meaning. Understands temporal queries like 'what happened today'. Scoped to your agent's own + granted memories.",
54
+ "description": "Search memories by meaning. Understands temporal queries like 'what happened today'. Scoped to your agent's own and other agents' non-private memories.",
55
55
  "inputSchema": {
56
56
  "type": "object",
57
57
  "properties": {
@@ -80,7 +80,7 @@ export const TOOL_DESCRIPTORS = [
80
80
  "query"
81
81
  ]
82
82
  },
83
- "outputShape": "{ results: MemoryRecord[] } — semantic hits scoped to the caller's own + granted memories; each hit carries content, never the raw embedding.",
83
+ "outputShape": "{ results: MemoryRecord[] } — semantic hits scoped to the caller's own and other agents' non-private memories; each hit carries content, never the raw embedding.",
84
84
  "annotations": {
85
85
  "readOnlyHint": true
86
86
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair-mcp",
3
- "version": "0.55.2",
3
+ "version": "0.57.0",
4
4
  "description": "MCP server for Flair — persistent memory for Claude Code, Cursor, and any MCP client.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "@modelcontextprotocol/sdk": "1.27.1",
32
- "@tpsdev-ai/flair-client": "0.55.2",
32
+ "@tpsdev-ai/flair-client": "0.57.0",
33
33
  "zod": "4.3.6"
34
34
  },
35
35
  "license": "Apache-2.0",