@tpsdev-ai/flair-mcp 0.15.0 → 0.16.1

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,7 +13,7 @@ cat > .mcp.json << 'EOF'
13
13
  "mcpServers": {
14
14
  "flair": {
15
15
  "command": "npx",
16
- "args": ["@tpsdev-ai/flair-mcp"],
16
+ "args": ["-y", "@tpsdev-ai/flair-mcp"],
17
17
  "env": {
18
18
  "FLAIR_AGENT_ID": "my-project"
19
19
  }
@@ -23,16 +23,15 @@ cat > .mcp.json << 'EOF'
23
23
  EOF
24
24
  ```
25
25
 
26
- Or install globally and configure once in `~/.claude/settings.json`.
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.)
27
27
 
28
28
  ### Prerequisites
29
29
 
30
- You need a running Flair instance:
30
+ You need a running Flair instance. The one-command front door:
31
31
 
32
32
  ```bash
33
33
  npm install -g @tpsdev-ai/flair
34
- flair init
35
- flair agent add my-project
34
+ flair init --agent my-project # installs Harper, creates the agent, wires MCP clients
36
35
  ```
37
36
 
38
37
  ## Tools
@@ -76,7 +75,7 @@ Point to a remote Flair instance:
76
75
  "mcpServers": {
77
76
  "flair": {
78
77
  "command": "npx",
79
- "args": ["@tpsdev-ai/flair-mcp"],
78
+ "args": ["-y", "@tpsdev-ai/flair-mcp"],
80
79
  "env": {
81
80
  "FLAIR_AGENT_ID": "my-project",
82
81
  "FLAIR_URL": "http://your-server:19926"
package/dist/index.d.ts CHANGED
@@ -14,9 +14,19 @@
14
14
  * - flair_orgevent — publish an OrgEvent attributed to self (no forging)
15
15
  *
16
16
  * Usage:
17
- * npx @tpsdev-ai/flair-mcp
17
+ * npx -y @tpsdev-ai/flair-mcp
18
18
  *
19
19
  * Claude Code .mcp.json:
20
- * { "mcpServers": { "flair": { "command": "npx", "args": ["@tpsdev-ai/flair-mcp"] } } }
20
+ * { "mcpServers": { "flair": { "command": "npx", "args": ["-y", "@tpsdev-ai/flair-mcp"] } } }
21
+ *
22
+ * BIN ENTRY / NODE-VERSION PREFLIGHT
23
+ * ----------------------------------
24
+ * The published `flair-mcp` bin is NOT this file — it is the CommonJS preflight
25
+ * shim (dist/mcp-shim.cjs, compiled from src/mcp-shim.cts). This module is an
26
+ * ES module: its top-level imports are hoisted and the whole graph is linked +
27
+ * evaluated before any in-file guard could run, so on an old Node it crashes
28
+ * during linking (the SDK + flair deps need Node >= 22) before printing anything
29
+ * — the silent `npx -y @tpsdev-ai/flair-mcp` failure. The shim checks the Node
30
+ * version FIRST, then dynamically imports this module and calls runMcp().
21
31
  */
22
- export {};
32
+ export declare function runMcp(): Promise<void>;
package/dist/index.js CHANGED
@@ -14,10 +14,20 @@
14
14
  * - flair_orgevent — publish an OrgEvent attributed to self (no forging)
15
15
  *
16
16
  * Usage:
17
- * npx @tpsdev-ai/flair-mcp
17
+ * npx -y @tpsdev-ai/flair-mcp
18
18
  *
19
19
  * Claude Code .mcp.json:
20
- * { "mcpServers": { "flair": { "command": "npx", "args": ["@tpsdev-ai/flair-mcp"] } } }
20
+ * { "mcpServers": { "flair": { "command": "npx", "args": ["-y", "@tpsdev-ai/flair-mcp"] } } }
21
+ *
22
+ * BIN ENTRY / NODE-VERSION PREFLIGHT
23
+ * ----------------------------------
24
+ * The published `flair-mcp` bin is NOT this file — it is the CommonJS preflight
25
+ * shim (dist/mcp-shim.cjs, compiled from src/mcp-shim.cts). This module is an
26
+ * ES module: its top-level imports are hoisted and the whole graph is linked +
27
+ * evaluated before any in-file guard could run, so on an old Node it crashes
28
+ * during linking (the SDK + flair deps need Node >= 22) before printing anything
29
+ * — the silent `npx -y @tpsdev-ai/flair-mcp` failure. The shim checks the Node
30
+ * version FIRST, then dynamically imports this module and calls runMcp().
21
31
  */
22
32
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
23
33
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
@@ -64,277 +74,307 @@ function classifyError(err, flairUrl) {
64
74
  function errorResult(err, flairUrl) {
65
75
  return { content: [{ type: "text", text: classifyError(err, flairUrl) }], isError: true };
66
76
  }
67
- // ─── Parent-exit watcher ────────────────────────────────────────────────────
68
- //
69
- // flair-mcp runs as a child of an MCP host (Claude Code, Cursor, etc) over
70
- // stdio. When the host exits cleanly it should close stdin/stdout — but in
71
- // practice we've seen flair-mcp processes orphaned for weeks (PID 1 as
72
- // parent), holding stale tokens and consuming RAM.
77
+ // ─── Entry point ──────────────────────────────────────────────────────────────
73
78
  //
74
- // Poll process.ppid every 5s. If it drops to 1 (init), the parent died and
75
- // we got reparented exit cleanly. Cheap, cross-platform, no native deps.
76
- // Clamp the poll interval to a safe range. `process.env.FOO ?? 5000` is NOT
77
- // safe on its own: `??` only falls through on null/undefined, so an empty-string
78
- // override (`FLAIR_MCP_PARENT_POLL_MS=`) yields `Number("") === 0` and creates
79
- // a tight CPU-busy loop. Validate explicitly. (Sherlock review on #315.)
80
- const PARENT_POLL_INTERVAL_MS = (() => {
81
- const raw = process.env.FLAIR_MCP_PARENT_POLL_MS;
82
- const parsed = raw != null ? Number(raw) : NaN;
83
- const FLOOR_MS = 100;
84
- const CEILING_MS = 30_000;
85
- return Number.isFinite(parsed) && parsed >= FLOOR_MS && parsed <= CEILING_MS
86
- ? parsed
87
- : 5000;
88
- })();
89
- const initialPpid = process.ppid;
90
- setInterval(() => {
91
- // ppid === 1 means init/launchd has adopted us original parent died.
92
- if (process.ppid === 1 && initialPpid !== 1) {
93
- console.error("flair-mcp: parent process died (re-parented to init); exiting cleanly.");
79
+ // runMcp() is the real entry point. It is exported so the CommonJS preflight
80
+ // shim (mcp-shim.cts dist/mcp-shim.cjs, the published bin) can invoke it after
81
+ // its Node-version check passes the shim imports this module, so a top-level
82
+ // `import.meta.main` guard would be false there. Everything that has runtime
83
+ // side effects (the FLAIR_AGENT_ID check, FlairClient construction, the parent-
84
+ // exit watcher, tool registration, and the stdio connect) lives inside runMcp()
85
+ // so that merely importing this module (e.g. from the shim before the version
86
+ // check, or from a test) does nothing until runMcp() is called.
87
+ export async function runMcp() {
88
+ // ─── Parent-exit watcher ──────────────────────────────────────────────────
89
+ //
90
+ // flair-mcp runs as a child of an MCP host (Claude Code, Cursor, etc) over
91
+ // stdio. When the host exits cleanly it should close stdin/stdout — but in
92
+ // practice we've seen flair-mcp processes orphaned for weeks (PID 1 as
93
+ // parent), holding stale tokens and consuming RAM.
94
+ //
95
+ // Poll process.ppid every 5s. If it drops to 1 (init), the parent died and
96
+ // we got reparented exit cleanly. Cheap, cross-platform, no native deps.
97
+ // Clamp the poll interval to a safe range. `process.env.FOO ?? 5000` is NOT
98
+ // safe on its own: `??` only falls through on null/undefined, so an empty-string
99
+ // override (`FLAIR_MCP_PARENT_POLL_MS=`) yields `Number("") === 0` and creates
100
+ // a tight CPU-busy loop. Validate explicitly. (Sherlock review on #315.)
101
+ const PARENT_POLL_INTERVAL_MS = (() => {
102
+ const raw = process.env.FLAIR_MCP_PARENT_POLL_MS;
103
+ const parsed = raw != null ? Number(raw) : NaN;
104
+ const FLOOR_MS = 100;
105
+ const CEILING_MS = 30_000;
106
+ return Number.isFinite(parsed) && parsed >= FLOOR_MS && parsed <= CEILING_MS
107
+ ? parsed
108
+ : 5000;
109
+ })();
110
+ const initialPpid = process.ppid;
111
+ setInterval(() => {
112
+ // ppid === 1 means init/launchd has adopted us — original parent died.
113
+ if (process.ppid === 1 && initialPpid !== 1) {
114
+ console.error("flair-mcp: parent process died (re-parented to init); exiting cleanly.");
115
+ process.exit(0);
116
+ }
117
+ }, PARENT_POLL_INTERVAL_MS).unref();
118
+ // Also handle stdin EOF — MCP host closing the pipe means session ended.
119
+ // (StdioServerTransport handles this internally for the MCP protocol, but
120
+ // belt-and-suspenders: if stdin closes we exit, full stop.)
121
+ process.stdin.on("close", () => {
122
+ console.error("flair-mcp: stdin closed; exiting cleanly.");
94
123
  process.exit(0);
124
+ });
125
+ process.stdin.on("end", () => {
126
+ console.error("flair-mcp: stdin EOF; exiting cleanly.");
127
+ process.exit(0);
128
+ });
129
+ // ─── Client setup ────────────────────────────────────────────────────────────
130
+ const agentId = process.env.FLAIR_AGENT_ID;
131
+ if (!agentId) {
132
+ console.error("FLAIR_AGENT_ID is required. Set it in your .mcp.json env or shell.");
133
+ process.exit(1);
95
134
  }
96
- }, PARENT_POLL_INTERVAL_MS).unref();
97
- // Also handle stdin EOF — MCP host closing the pipe means session ended.
98
- // (StdioServerTransport handles this internally for the MCP protocol, but
99
- // belt-and-suspenders: if stdin closes we exit, full stop.)
100
- process.stdin.on("close", () => {
101
- console.error("flair-mcp: stdin closed; exiting cleanly.");
102
- process.exit(0);
103
- });
104
- process.stdin.on("end", () => {
105
- console.error("flair-mcp: stdin EOF; exiting cleanly.");
106
- process.exit(0);
107
- });
108
- // ─── Client setup ────────────────────────────────────────────────────────────
109
- const agentId = process.env.FLAIR_AGENT_ID;
110
- if (!agentId) {
111
- console.error("FLAIR_AGENT_ID is required. Set it in your .mcp.json env or shell.");
112
- process.exit(1);
113
- }
114
- const flair = new FlairClient({
115
- agentId,
116
- url: process.env.FLAIR_URL,
117
- keyPath: process.env.FLAIR_KEY_PATH,
118
- });
119
- // ─── MCP Server ──────────────────────────────────────────────────────────────
120
- const server = new McpServer({
121
- name: "flair",
122
- version: "0.1.0",
123
- });
124
- // ─── Tools ───────────────────────────────────────────────────────────────────
125
- server.tool("memory_search", "Search memories by meaning. Understands temporal queries like 'what happened today'.", {
126
- query: z.string().describe("Search query — natural language, semantic matching"),
127
- limit: z.coerce.number().optional().default(5).describe("Max results (default 5)"),
128
- }, async ({ query, limit }) => {
129
- try {
130
- const results = await flair.memory.search(query, { limit });
131
- if (results.length === 0) {
132
- return { content: [{ type: "text", text: "No relevant memories found." }] };
135
+ const flair = new FlairClient({
136
+ agentId,
137
+ url: process.env.FLAIR_URL,
138
+ keyPath: process.env.FLAIR_KEY_PATH,
139
+ });
140
+ // ─── MCP Server ──────────────────────────────────────────────────────────────
141
+ const server = new McpServer({
142
+ name: "flair",
143
+ version: "0.1.0",
144
+ });
145
+ // ─── Tools ───────────────────────────────────────────────────────────────────
146
+ server.tool("memory_search", "Search memories by meaning. Understands temporal queries like 'what happened today'.", {
147
+ query: z.string().describe("Search query natural language, semantic matching"),
148
+ limit: z.coerce.number().optional().default(5).describe("Max results (default 5)"),
149
+ }, async ({ query, limit }) => {
150
+ try {
151
+ const results = await flair.memory.search(query, { limit });
152
+ if (results.length === 0) {
153
+ return { content: [{ type: "text", text: "No relevant memories found." }] };
154
+ }
155
+ const text = results
156
+ .map((r, i) => {
157
+ const date = r.createdAt ? r.createdAt.slice(0, 10) : "";
158
+ const idStr = r.id ? `id:${r.id}` : "";
159
+ const meta = [date, r.type, idStr].filter(Boolean).join(", ");
160
+ return `${i + 1}. ${r.content}${meta ? ` (${meta})` : ""}`;
161
+ })
162
+ .join("\n");
163
+ return { content: [{ type: "text", text }] };
133
164
  }
134
- const text = results
135
- .map((r, i) => {
136
- const date = r.createdAt ? r.createdAt.slice(0, 10) : "";
137
- const idStr = r.id ? `id:${r.id}` : "";
138
- const meta = [date, r.type, idStr].filter(Boolean).join(", ");
139
- return `${i + 1}. ${r.content}${meta ? ` (${meta})` : ""}`;
140
- })
141
- .join("\n");
142
- return { content: [{ type: "text", text }] };
143
- }
144
- catch (err) {
145
- return errorResult(err, flair.url);
146
- }
147
- });
148
- server.tool("memory_store", "Save information to persistent memory. Use for lessons, decisions, preferences, facts.", {
149
- content: z.string().describe("What to remember"),
150
- type: z.enum(["session", "lesson", "decision", "preference", "fact", "goal"]).optional().default("session"),
151
- durability: z.enum(["permanent", "persistent", "standard", "ephemeral"]).optional().default("standard")
152
- .describe("permanent — inviolable facts, identity, explicit never-forget (e.g., 'my name is Nathan')\n" +
153
- "persistent — key decisions and lessons to recall weeks later (e.g., 'PR review process')\n" +
154
- "standard — default working memory, recent context (e.g., 'discussed auth flow today')\n" +
155
- "ephemeral — scratch state, auto-expires 72h (e.g., 'currently debugging issue #42')"),
156
- tags: z.array(z.string()).optional().describe("Array of tag strings"),
157
- }, async ({ content, type, durability, tags }) => {
158
- try {
159
- const result = await flair.memory.write(content, {
160
- type: type,
161
- durability: durability,
162
- tags,
163
- dedup: true,
164
- dedupThreshold: 0.95,
165
- });
166
- // Dedup path: existing memory matched, NEW content was NOT written.
167
- // Emit both prose AND structuredContent so callers can react
168
- // programmatically even when LLMs compress prose imprecisely
169
- // (see flair#449 — work agent missed dedup signal and lost data).
170
- if (result.deduped) {
171
- const sc = {
172
- deduplicated: true,
173
- mergedWith: result.id,
174
- existingContent: result.content,
175
- written: false,
176
- };
165
+ catch (err) {
166
+ return errorResult(err, flair.url);
167
+ }
168
+ });
169
+ server.tool("memory_store", "Save information to persistent memory. Use for lessons, decisions, preferences, facts.", {
170
+ content: z.string().describe("What to remember"),
171
+ type: z.enum(["session", "lesson", "decision", "preference", "fact", "goal"]).optional().default("session"),
172
+ durability: z.enum(["permanent", "persistent", "standard", "ephemeral"]).optional().default("standard")
173
+ .describe("permanent inviolable facts, identity, explicit never-forget (e.g., 'my name is Nathan')\n" +
174
+ "persistent — key decisions and lessons to recall weeks later (e.g., 'PR review process')\n" +
175
+ "standard — default working memory, recent context (e.g., 'discussed auth flow today')\n" +
176
+ "ephemeral — scratch state, auto-expires 72h (e.g., 'currently debugging issue #42')"),
177
+ tags: z.array(z.string()).optional().describe("Array of tag strings"),
178
+ }, async ({ content, type, durability, tags }) => {
179
+ try {
180
+ const result = await flair.memory.write(content, {
181
+ type: type,
182
+ durability: durability,
183
+ tags,
184
+ dedup: true,
185
+ dedupThreshold: 0.95,
186
+ });
187
+ // Dedup path: existing memory matched, NEW content was NOT written.
188
+ // Emit both prose AND structuredContent so callers can react
189
+ // programmatically even when LLMs compress prose imprecisely
190
+ // (see flair#449 work agent missed dedup signal and lost data).
191
+ if (result.deduped) {
192
+ const sc = {
193
+ deduplicated: true,
194
+ mergedWith: result.id,
195
+ existingContent: result.content,
196
+ written: false,
197
+ };
198
+ return {
199
+ content: [{
200
+ type: "text",
201
+ text: `⚠️ DEDUPLICATED — new content was NOT written. ` +
202
+ `Matched existing memory id=${result.id}: ${result.content?.slice(0, 200)}\n\n` +
203
+ `If the new content is genuinely distinct, retry with different phrasing ` +
204
+ `or call memory_store with the same id to update.`,
205
+ }],
206
+ structuredContent: sc,
207
+ };
208
+ }
209
+ const preview = content.length > 120 ? content.slice(0, 120) + "..." : content;
210
+ const tagStr = tags && tags.length > 0 ? tags.join(", ") : "none";
211
+ const text = [
212
+ `Memory stored (id: ${result.id})`,
213
+ `Preview: ${preview}`,
214
+ `Size: ${content.length} chars`,
215
+ `Tags: ${tagStr}`,
216
+ `Type: ${type}, Durability: ${durability}`,
217
+ ].join("\n");
177
218
  return {
178
- content: [{
179
- type: "text",
180
- text: `⚠️ DEDUPLICATED — new content was NOT written. ` +
181
- `Matched existing memory id=${result.id}: ${result.content?.slice(0, 200)}\n\n` +
182
- `If the new content is genuinely distinct, retry with different phrasing ` +
183
- `or call memory_store with the same id to update.`,
184
- }],
185
- structuredContent: sc,
219
+ content: [{ type: "text", text }],
220
+ structuredContent: { deduplicated: false, id: result.id, written: true },
186
221
  };
187
222
  }
188
- const preview = content.length > 120 ? content.slice(0, 120) + "..." : content;
189
- const tagStr = tags && tags.length > 0 ? tags.join(", ") : "none";
190
- const text = [
191
- `Memory stored (id: ${result.id})`,
192
- `Preview: ${preview}`,
193
- `Size: ${content.length} chars`,
194
- `Tags: ${tagStr}`,
195
- `Type: ${type}, Durability: ${durability}`,
196
- ].join("\n");
197
- return {
198
- content: [{ type: "text", text }],
199
- structuredContent: { deduplicated: false, id: result.id, written: true },
200
- };
201
- }
202
- catch (err) {
203
- return errorResult(err, flair.url);
204
- }
205
- });
206
- server.tool("memory_get", "Retrieve a specific memory by ID.", {
207
- id: z.string().describe("Memory ID"),
208
- }, async ({ id }) => {
209
- try {
210
- const mem = await flair.memory.get(id);
211
- if (!mem)
212
- return { content: [{ type: "text", text: `Memory ${id} not found.` }] };
213
- return { content: [{ type: "text", text: `${mem.content}\n\n(type: ${mem.type}, durability: ${mem.durability}, created: ${mem.createdAt})` }] };
214
- }
215
- catch (err) {
216
- return errorResult(err, flair.url);
217
- }
218
- });
219
- server.tool("memory_delete", "Delete a memory by ID.", {
220
- id: z.string().describe("Memory ID to delete"),
221
- }, async ({ id }) => {
222
- try {
223
- await flair.memory.delete(id);
224
- return { content: [{ type: "text", text: `Memory ${id} deleted.` }] };
225
- }
226
- catch (err) {
227
- return errorResult(err, flair.url);
228
- }
229
- });
230
- server.tool("bootstrap", "Get session context: soul + memories + predicted context. Run at session start. Pass subjects for predictive loading.", {
231
- maxTokens: z.coerce.number().optional().default(4000).describe("Max tokens in output"),
232
- currentTask: z.string().optional().describe("Current task description — enables semantic search for relevant memories"),
233
- channel: z.string().optional().describe("Channel name (discord, tps-mail, claude-code) — shapes context prediction"),
234
- surface: z.string().optional().describe("Surface name (tps-build, tps-review, cli-session) — narrows prediction"),
235
- subjects: z.array(z.string()).optional().describe("Entity names to preload context for (e.g., ['flair', 'auth'])"),
236
- }, async ({ maxTokens, currentTask, channel, surface, subjects }) => {
237
- try {
238
- const result = await flair.bootstrap({ maxTokens, currentTask, channel, surface, subjects });
239
- if (!result.context) {
240
- return { content: [{ type: "text", text: "No context available." }] };
223
+ catch (err) {
224
+ return errorResult(err, flair.url);
241
225
  }
242
- return { content: [{ type: "text", text: result.context }] };
243
- }
244
- catch (err) {
245
- return errorResult(err, flair.url);
246
- }
247
- });
248
- server.tool("soul_set", "Set a personality or project context entry. Included in every bootstrap.", {
249
- key: z.string().describe("Entry key (e.g., 'role', 'standards', 'project')"),
250
- value: z.string().describe("Entry value personality trait, project context, coding standards, etc."),
251
- }, async ({ key, value }) => {
252
- try {
253
- await flair.soul.set(key, value);
254
- return { content: [{ type: "text", text: `Soul entry '${key}' set.` }] };
255
- }
256
- catch (err) {
257
- return errorResult(err, flair.url);
258
- }
259
- });
260
- server.tool("soul_get", "Get a personality or project context entry.", {
261
- key: z.string().describe("Entry key"),
262
- }, async ({ key }) => {
263
- try {
264
- const entry = await flair.soul.get(key);
265
- if (!entry)
266
- return { content: [{ type: "text", text: `No soul entry for '${key}'.` }] };
267
- return { content: [{ type: "text", text: entry.value }] };
268
- }
269
- catch (err) {
270
- return errorResult(err, flair.url);
271
- }
272
- });
273
- // ─── Coordination write surface ──────────────────────────────────────────────
226
+ });
227
+ server.tool("memory_get", "Retrieve a specific memory by ID.", {
228
+ id: z.string().describe("Memory ID"),
229
+ }, async ({ id }) => {
230
+ try {
231
+ const mem = await flair.memory.get(id);
232
+ if (!mem)
233
+ return { content: [{ type: "text", text: `Memory ${id} not found.` }] };
234
+ return { content: [{ type: "text", text: `${mem.content}\n\n(type: ${mem.type}, durability: ${mem.durability}, created: ${mem.createdAt})` }] };
235
+ }
236
+ catch (err) {
237
+ return errorResult(err, flair.url);
238
+ }
239
+ });
240
+ server.tool("memory_delete", "Delete a memory by ID.", {
241
+ id: z.string().describe("Memory ID to delete"),
242
+ }, async ({ id }) => {
243
+ try {
244
+ await flair.memory.delete(id);
245
+ return { content: [{ type: "text", text: `Memory ${id} deleted.` }] };
246
+ }
247
+ catch (err) {
248
+ return errorResult(err, flair.url);
249
+ }
250
+ });
251
+ server.tool("bootstrap", "Get session context: soul + memories + predicted context. Run at session start. Pass subjects for predictive loading.", {
252
+ maxTokens: z.coerce.number().optional().default(4000).describe("Max tokens in output"),
253
+ currentTask: z.string().optional().describe("Current task description — enables semantic search for relevant memories"),
254
+ channel: z.string().optional().describe("Channel name (discord, tps-mail, claude-code) — shapes context prediction"),
255
+ surface: z.string().optional().describe("Surface name (tps-build, tps-review, cli-session) — narrows prediction"),
256
+ subjects: z.array(z.string()).optional().describe("Entity names to preload context for (e.g., ['flair', 'auth'])"),
257
+ }, async ({ maxTokens, currentTask, channel, surface, subjects }) => {
258
+ try {
259
+ const result = await flair.bootstrap({ maxTokens, currentTask, channel, surface, subjects });
260
+ if (!result.context) {
261
+ return { content: [{ type: "text", text: "No context available." }] };
262
+ }
263
+ return { content: [{ type: "text", text: result.context }] };
264
+ }
265
+ catch (err) {
266
+ return errorResult(err, flair.url);
267
+ }
268
+ });
269
+ server.tool("soul_set", "Set a personality or project context entry. Included in every bootstrap.", {
270
+ key: z.string().describe("Entry key (e.g., 'role', 'standards', 'project')"),
271
+ value: z.string().describe("Entry value — personality trait, project context, coding standards, etc."),
272
+ }, async ({ key, value }) => {
273
+ try {
274
+ await flair.soul.set(key, value);
275
+ return { content: [{ type: "text", text: `Soul entry '${key}' set.` }] };
276
+ }
277
+ catch (err) {
278
+ return errorResult(err, flair.url);
279
+ }
280
+ });
281
+ server.tool("soul_get", "Get a personality or project context entry.", {
282
+ key: z.string().describe("Entry key"),
283
+ }, async ({ key }) => {
284
+ try {
285
+ const entry = await flair.soul.get(key);
286
+ if (!entry)
287
+ return { content: [{ type: "text", text: `No soul entry for '${key}'.` }] };
288
+ return { content: [{ type: "text", text: entry.value }] };
289
+ }
290
+ catch (err) {
291
+ return errorResult(err, flair.url);
292
+ }
293
+ });
294
+ // ─── Coordination write surface ──────────────────────────────────────────────
295
+ //
296
+ // flair_workspace_set + flair_orgevent let an agent write the Office Space
297
+ // coordination layer without hand-rolling signed HTTP. Both go through
298
+ // flair.request(), which signs with the agent's Ed25519 key — so identity
299
+ // (WorkspaceState.agentId / OrgEvent.authorId) is taken from the SIGNATURE on
300
+ // the server side, NEVER the body. We deliberately do NOT send agentId/authorId
301
+ // in the body; the handlers attribute the write to the authenticated agent, so
302
+ // an agent can only write AS itself (no forging).
303
+ 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.", {
304
+ ref: z.string().describe("Workspace ref — branch, worktree, or task ref"),
305
+ label: z.string().optional().describe("Human-readable label for this workspace"),
306
+ provider: z.string().optional().default("mcp").describe("Provider/runtime (e.g. claude-code, openclaw)"),
307
+ task: z.string().optional().describe("Task/issue id this workspace is attached to"),
308
+ phase: z.string().optional().describe("Current phase (e.g. design, implement, review)"),
309
+ summary: z.string().optional().describe("Short summary of current workspace state"),
310
+ }, async ({ ref, label, provider, task, phase, summary }) => {
311
+ try {
312
+ // No agentId in body — the server attributes from the signed identity.
313
+ const body = {
314
+ id: `${agentId}:${ref}`,
315
+ ref,
316
+ provider: provider ?? "mcp",
317
+ timestamp: new Date().toISOString(),
318
+ };
319
+ if (label)
320
+ body.label = label;
321
+ if (task)
322
+ body.taskId = task;
323
+ if (phase)
324
+ body.phase = phase;
325
+ if (summary)
326
+ body.summary = summary;
327
+ await flair.request("POST", "/WorkspaceState", body);
328
+ return { content: [{ type: "text", text: `Workspace state set: ref=${ref}${phase ? `, phase=${phase}` : ""} (attributed to ${agentId}).` }] };
329
+ }
330
+ catch (err) {
331
+ return errorResult(err, flair.url);
332
+ }
333
+ });
334
+ 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.", {
335
+ kind: z.string().describe("Event kind (e.g. coord.claim, coord.release, status)"),
336
+ summary: z.string().describe("Short summary of the event"),
337
+ detail: z.string().optional().describe("Longer detail payload"),
338
+ scope: z.string().optional().describe("Scope of the event (e.g. an agent id, repo, or 'org')"),
339
+ targets: z.array(z.string()).optional().describe("Recipient agent ids"),
340
+ }, async ({ kind, summary, detail, scope, targets }) => {
341
+ try {
342
+ // No authorId in body — the server attributes from the signed identity.
343
+ const body = { kind, summary };
344
+ if (detail)
345
+ body.detail = detail;
346
+ if (scope)
347
+ body.scope = scope;
348
+ if (targets && targets.length > 0)
349
+ body.targetIds = targets;
350
+ const result = await flair.request("POST", "/OrgEvent", body);
351
+ const targetStr = targets && targets.length > 0 ? ` → ${targets.join(", ")}` : "";
352
+ const idStr = result?.id ? ` (id: ${result.id})` : "";
353
+ return { content: [{ type: "text", text: `OrgEvent published: kind=${kind}${targetStr} (attributed to ${agentId})${idStr}.` }] };
354
+ }
355
+ catch (err) {
356
+ return errorResult(err, flair.url);
357
+ }
358
+ });
359
+ // ─── Start ───────────────────────────────────────────────────────────────────
360
+ const transport = new StdioServerTransport();
361
+ await server.connect(transport);
362
+ }
363
+ // ─── Entry point dispatch ──────────────────────────────────────────────────────
274
364
  //
275
- // flair_workspace_set + flair_orgevent let an agent write the Office Space
276
- // coordination layer without hand-rolling signed HTTP. Both go through
277
- // flair.request(), which signs with the agent's Ed25519 key so identity
278
- // (WorkspaceState.agentId / OrgEvent.authorId) is taken from the SIGNATURE on
279
- // the server side, NEVER the body. We deliberately do NOT send agentId/authorId
280
- // in the body; the handlers attribute the write to the authenticated agent, so
281
- // an agent can only write AS itself (no forging).
282
- 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.", {
283
- ref: z.string().describe("Workspace ref branch, worktree, or task ref"),
284
- label: z.string().optional().describe("Human-readable label for this workspace"),
285
- provider: z.string().optional().default("mcp").describe("Provider/runtime (e.g. claude-code, openclaw)"),
286
- task: z.string().optional().describe("Task/issue id this workspace is attached to"),
287
- phase: z.string().optional().describe("Current phase (e.g. design, implement, review)"),
288
- summary: z.string().optional().describe("Short summary of current workspace state"),
289
- }, async ({ ref, label, provider, task, phase, summary }) => {
290
- try {
291
- // No agentId in body — the server attributes from the signed identity.
292
- const body = {
293
- id: `${agentId}:${ref}`,
294
- ref,
295
- provider: provider ?? "mcp",
296
- timestamp: new Date().toISOString(),
297
- };
298
- if (label)
299
- body.label = label;
300
- if (task)
301
- body.taskId = task;
302
- if (phase)
303
- body.phase = phase;
304
- if (summary)
305
- body.summary = summary;
306
- await flair.request("POST", "/WorkspaceState", body);
307
- return { content: [{ type: "text", text: `Workspace state set: ref=${ref}${phase ? `, phase=${phase}` : ""} (attributed to ${agentId}).` }] };
308
- }
309
- catch (err) {
310
- return errorResult(err, flair.url);
311
- }
312
- });
313
- 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.", {
314
- kind: z.string().describe("Event kind (e.g. coord.claim, coord.release, status)"),
315
- summary: z.string().describe("Short summary of the event"),
316
- detail: z.string().optional().describe("Longer detail payload"),
317
- scope: z.string().optional().describe("Scope of the event (e.g. an agent id, repo, or 'org')"),
318
- targets: z.array(z.string()).optional().describe("Recipient agent ids"),
319
- }, async ({ kind, summary, detail, scope, targets }) => {
320
- try {
321
- // No authorId in body — the server attributes from the signed identity.
322
- const body = { kind, summary };
323
- if (detail)
324
- body.detail = detail;
325
- if (scope)
326
- body.scope = scope;
327
- if (targets && targets.length > 0)
328
- body.targetIds = targets;
329
- const result = await flair.request("POST", "/OrgEvent", body);
330
- const targetStr = targets && targets.length > 0 ? ` → ${targets.join(", ")}` : "";
331
- const idStr = result?.id ? ` (id: ${result.id})` : "";
332
- return { content: [{ type: "text", text: `OrgEvent published: kind=${kind}${targetStr} (attributed to ${agentId})${idStr}.` }] };
333
- }
334
- catch (err) {
335
- return errorResult(err, flair.url);
336
- }
337
- });
338
- // ─── Start ───────────────────────────────────────────────────────────────────
339
- const transport = new StdioServerTransport();
340
- await server.connect(transport);
365
+ // Run directly when this module is the entry point — covers `bun src/index.ts`
366
+ // and `node dist/index.js`. The packaged bin goes through mcp-shim.cjs runMcp()
367
+ // after its Node-version check, so import.meta.main is false there; without this
368
+ // the server would never start when invoked through the shim. (Matches the
369
+ // session-start-hook + CLI shim entry-point pattern.)
370
+ const importMeta = import.meta;
371
+ const isMain = importMeta.main === true ||
372
+ (typeof process !== "undefined" &&
373
+ process.argv[1] != null &&
374
+ import.meta.url === `file://${process.argv[1]}`);
375
+ if (isMain) {
376
+ void runMcp().catch((err) => {
377
+ console.error(err && err.stack ? err.stack : err);
378
+ process.exit(1);
379
+ });
380
+ }
@@ -0,0 +1,68 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * mcp-shim.cts — Node-version PREFLIGHT for the Flair MCP server.
5
+ *
6
+ * THIS IS THE BIN ENTRY (`package.json` "bin": { "flair-mcp": "dist/mcp-shim.cjs" }).
7
+ *
8
+ * Why a separate CommonJS file instead of a guard inside index.ts:
9
+ * The real MCP server (dist/index.js) is an ES module. In ESM, every top-level
10
+ * `import` is HOISTED and the whole module graph is LINKED + EVALUATED before
11
+ * the first statement in the file body runs. flair-mcp's deps (@modelcontext-
12
+ * protocol/sdk, @tpsdev-ai/flair-client and its transitive deps) require a
13
+ * modern engine, so on an older Node the import graph fails to load — and a
14
+ * Node-version check placed even at the very top of index.ts never executes:
15
+ * the import graph crashes first. That is exactly the silent-failure bug — a
16
+ * user wiring `npx -y @tpsdev-ai/flair-mcp` on an old Node gets zero output and
17
+ * a dead MCP server, with no actionable signal (the same exposure flair's CLI
18
+ * had before #524).
19
+ *
20
+ * CommonJS evaluates top-to-bottom and `require()`/`import()` are evaluated
21
+ * lazily — so the version check below runs and prints BEFORE anything tries to
22
+ * load the ESM server or any modern dependency. Because every Node since v0.x
23
+ * parses and runs CommonJS, this shim is guaranteed to run and print on the
24
+ * oldest Node a user could plausibly have.
25
+ *
26
+ * The check itself deliberately uses ONLY ancient-safe syntax — `var`, plain
27
+ * functions, string `.split`/`parseInt`, `console.error`, `process.exit`. No
28
+ * top-level await, no optional chaining, no template literals reaching
29
+ * modern-only APIs — so the guard can never become the thing that fails to
30
+ * parse.
31
+ */
32
+ Object.defineProperty(exports, "__esModule", { value: true });
33
+ // ── Node-version preflight (must stay parse-safe + runtime-safe on old Node) ──
34
+ var MIN_NODE_MAJOR = 22;
35
+ function flairMcpCurrentNodeMajor() {
36
+ // process.versions.node is e.g. "18.20.4"; take the leading integer.
37
+ var raw = (process && process.versions && process.versions.node) || "0";
38
+ return parseInt(String(raw).split(".")[0], 10) || 0;
39
+ }
40
+ var flairMcpNodeMajor = flairMcpCurrentNodeMajor();
41
+ if (flairMcpNodeMajor < MIN_NODE_MAJOR) {
42
+ // Plain string concatenation — no template literals, no chalk, nothing that
43
+ // could itself trip on an old engine.
44
+ console.error("");
45
+ console.error(" flair-mcp requires Node.js >= " + MIN_NODE_MAJOR + ".");
46
+ console.error(" You are running Node.js " + (process.versions && process.versions.node ? process.versions.node : "(unknown)") + ".");
47
+ console.error("");
48
+ console.error(" Please upgrade Node and try again:");
49
+ console.error(" https://nodejs.org/ (or use nvm / fnm / volta)");
50
+ console.error("");
51
+ process.exit(1);
52
+ }
53
+ // Node is new enough — hand off to the real ESM MCP server. Dynamic import()
54
+ // from CommonJS is the supported ESM-from-CJS bridge. We only reach this line on
55
+ // a supported Node, so the import() expression is never evaluated on an engine
56
+ // that can't load the ESM graph.
57
+ import("./index.js")
58
+ .then(function (mod) {
59
+ if (mod && typeof mod.runMcp === "function") {
60
+ return mod.runMcp();
61
+ }
62
+ // Older builds auto-ran on import via import.meta.main; nothing to call.
63
+ return undefined;
64
+ })
65
+ .catch(function (err) {
66
+ console.error(err && err.stack ? err.stack : err);
67
+ process.exit(1);
68
+ });
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mcp-shim.cts — Node-version PREFLIGHT for the Flair MCP server.
4
+ *
5
+ * THIS IS THE BIN ENTRY (`package.json` "bin": { "flair-mcp": "dist/mcp-shim.cjs" }).
6
+ *
7
+ * Why a separate CommonJS file instead of a guard inside index.ts:
8
+ * The real MCP server (dist/index.js) is an ES module. In ESM, every top-level
9
+ * `import` is HOISTED and the whole module graph is LINKED + EVALUATED before
10
+ * the first statement in the file body runs. flair-mcp's deps (@modelcontext-
11
+ * protocol/sdk, @tpsdev-ai/flair-client and its transitive deps) require a
12
+ * modern engine, so on an older Node the import graph fails to load — and a
13
+ * Node-version check placed even at the very top of index.ts never executes:
14
+ * the import graph crashes first. That is exactly the silent-failure bug — a
15
+ * user wiring `npx -y @tpsdev-ai/flair-mcp` on an old Node gets zero output and
16
+ * a dead MCP server, with no actionable signal (the same exposure flair's CLI
17
+ * had before #524).
18
+ *
19
+ * CommonJS evaluates top-to-bottom and `require()`/`import()` are evaluated
20
+ * lazily — so the version check below runs and prints BEFORE anything tries to
21
+ * load the ESM server or any modern dependency. Because every Node since v0.x
22
+ * parses and runs CommonJS, this shim is guaranteed to run and print on the
23
+ * oldest Node a user could plausibly have.
24
+ *
25
+ * The check itself deliberately uses ONLY ancient-safe syntax — `var`, plain
26
+ * functions, string `.split`/`parseInt`, `console.error`, `process.exit`. No
27
+ * top-level await, no optional chaining, no template literals reaching
28
+ * modern-only APIs — so the guard can never become the thing that fails to
29
+ * parse.
30
+ */
31
+ export {};
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair-mcp",
3
- "version": "0.15.0",
3
+ "version": "0.16.1",
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",
7
7
  "bin": {
8
- "flair-mcp": "dist/index.js",
8
+ "flair-mcp": "dist/mcp-shim.cjs",
9
9
  "flair-session-start": "dist/session-start-hook.js"
10
10
  },
11
11
  "files": [
@@ -17,17 +17,17 @@
17
17
  "build": "tsc --noCheck",
18
18
  "test": "bun test",
19
19
  "prepublishOnly": "npm run build",
20
- "postinstall": "node -e \"try{const{chmodSync,statSync}=require('fs');for(const p of ['dist/index.js','dist/session-start-hook.js']){try{if(statSync(p).isFile()){chmodSync(p,0o755);console.error('@tpsdev-ai/flair-mcp: chmod +x ' + p + ' OK')}}catch(e){if(e.code!=='ENOENT')console.error('postinstall warn:',e.message)}}}catch(e){console.error('postinstall warn:',e.message)}\""
20
+ "postinstall": "node -e \"try{const{chmodSync,statSync}=require('fs');for(const p of ['dist/mcp-shim.cjs','dist/index.js','dist/session-start-hook.js']){try{if(statSync(p).isFile()){chmodSync(p,0o755);console.error('@tpsdev-ai/flair-mcp: chmod +x ' + p + ' OK')}}catch(e){if(e.code!=='ENOENT')console.error('postinstall warn:',e.message)}}}catch(e){console.error('postinstall warn:',e.message)}\""
21
21
  },
22
22
  "publishConfig": {
23
23
  "access": "public"
24
24
  },
25
25
  "engines": {
26
- "node": ">=18"
26
+ "node": ">=22"
27
27
  },
28
28
  "dependencies": {
29
29
  "@modelcontextprotocol/sdk": "1.27.1",
30
- "@tpsdev-ai/flair-client": "0.15.0",
30
+ "@tpsdev-ai/flair-client": "0.16.1",
31
31
  "zod": "4.3.6"
32
32
  },
33
33
  "license": "Apache-2.0",