@tpsdev-ai/flair-mcp 0.44.12 → 0.45.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.
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Interpolation-literal env guard (flair#1250).
3
+ *
4
+ * An MCP host config template often forwards env like
5
+ * "env": { "FLAIR_URL": "${FLAIR_URL}" }
6
+ * expecting the host to substitute `${FLAIR_URL}` with the real value. When the
7
+ * variable is unset and the host does NOT substitute (Cursor's plugin `mcp.json`
8
+ * is exactly this shape — see packages/cursor-flair/mcp.json), flair-mcp is
9
+ * handed the LITERAL string `"${FLAIR_URL}"`. That literal is truthy, so it wins
10
+ * every `value ?? default` / `value || default` chain and the sensible default
11
+ * (flair-client's `http://localhost:19926`) never applies — the connection then
12
+ * fails in a way that points nowhere near the real cause.
13
+ *
14
+ * This module treats such a literal as "unset" so the default applies instead.
15
+ */
16
+ /**
17
+ * True iff `value` is an unsubstituted interpolation literal like `${FLAIR_URL}`
18
+ * — i.e. the whole (trimmed) value is a single `${...}` placeholder the host
19
+ * never expanded. A value that merely CONTAINS a `${...}` (e.g. a real URL with
20
+ * a fragment) is left alone: only a wholesale placeholder is treated as unset.
21
+ */
22
+ export declare function isUnsubstitutedInterpolation(value: string): boolean;
23
+ /**
24
+ * Read an env var, treating an unsubstituted `${...}` interpolation literal as
25
+ * unset: returns `undefined` for such a literal (and for a genuinely absent
26
+ * var) so a downstream default applies instead of the literal being used
27
+ * verbatim. A real value is returned unchanged (NOT trimmed — only the
28
+ * placeholder check trims).
29
+ */
30
+ export declare function readEnvOrUnset(name: string, env?: NodeJS.ProcessEnv): string | undefined;
31
+ /**
32
+ * Connection env vars that flair-client's OWN constructor reads straight from
33
+ * `process.env` as a fallback (see flair-client/src/client.ts:
34
+ * `this.url = config.url ?? readEnvOrUnset("FLAIR_URL") ?? DEFAULT_URL`). For
35
+ * these, skipping the literal only at flair-mcp's call site was NOT enough:
36
+ * flair-client re-read `process.env` and resurrected the `${...}` literal,
37
+ * defeating its own default. As of flair#1254 flair-client's env fallbacks
38
+ * apply this same literal-as-unset guard themselves, so the strip below is
39
+ * defense-in-depth rather than the only line — kept deliberately (an older
40
+ * flair-client on a consumer's disk does not have #1254).
41
+ *
42
+ * Only FLAIR_URL qualifies today: it is re-read by flair-client AND flair-mcp
43
+ * still constructs a client when it is a literal. FLAIR_AGENT_ID is also re-read
44
+ * by flair-client, but flair-mcp's `if (!agentId)` guard (using readEnvOrUnset)
45
+ * short-circuits before any client is built, so no strip is needed. FLAIR_KEY_PATH
46
+ * flair-client never reads from env, so guarding it at the call site suffices.
47
+ */
48
+ export declare const ENV_RESURRECTED_BY_FLAIR_CLIENT: readonly ["FLAIR_URL"];
49
+ /**
50
+ * Delete any of `names` whose value is an unsubstituted `${...}` interpolation
51
+ * literal from `env`, so downstream readers — flair-mcp's own reads AND
52
+ * flair-client's internal `process.env` fallback — see the var as unset and
53
+ * apply their default. Idempotent; mutates the live process env by default and
54
+ * accepts an injectable env for tests. Real (substituted) values are untouched.
55
+ */
56
+ export declare function stripInterpolationLiteralsFromEnv(env?: NodeJS.ProcessEnv, names?: readonly string[]): void;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Interpolation-literal env guard (flair#1250).
3
+ *
4
+ * An MCP host config template often forwards env like
5
+ * "env": { "FLAIR_URL": "${FLAIR_URL}" }
6
+ * expecting the host to substitute `${FLAIR_URL}` with the real value. When the
7
+ * variable is unset and the host does NOT substitute (Cursor's plugin `mcp.json`
8
+ * is exactly this shape — see packages/cursor-flair/mcp.json), flair-mcp is
9
+ * handed the LITERAL string `"${FLAIR_URL}"`. That literal is truthy, so it wins
10
+ * every `value ?? default` / `value || default` chain and the sensible default
11
+ * (flair-client's `http://localhost:19926`) never applies — the connection then
12
+ * fails in a way that points nowhere near the real cause.
13
+ *
14
+ * This module treats such a literal as "unset" so the default applies instead.
15
+ */
16
+ /**
17
+ * True iff `value` is an unsubstituted interpolation literal like `${FLAIR_URL}`
18
+ * — i.e. the whole (trimmed) value is a single `${...}` placeholder the host
19
+ * never expanded. A value that merely CONTAINS a `${...}` (e.g. a real URL with
20
+ * a fragment) is left alone: only a wholesale placeholder is treated as unset.
21
+ */
22
+ export function isUnsubstitutedInterpolation(value) {
23
+ return /^\$\{.*\}$/.test(value.trim());
24
+ }
25
+ /**
26
+ * Read an env var, treating an unsubstituted `${...}` interpolation literal as
27
+ * unset: returns `undefined` for such a literal (and for a genuinely absent
28
+ * var) so a downstream default applies instead of the literal being used
29
+ * verbatim. A real value is returned unchanged (NOT trimmed — only the
30
+ * placeholder check trims).
31
+ */
32
+ export function readEnvOrUnset(name, env = process.env) {
33
+ const v = env[name];
34
+ if (v === undefined)
35
+ return undefined;
36
+ return isUnsubstitutedInterpolation(v) ? undefined : v;
37
+ }
38
+ /**
39
+ * Connection env vars that flair-client's OWN constructor reads straight from
40
+ * `process.env` as a fallback (see flair-client/src/client.ts:
41
+ * `this.url = config.url ?? readEnvOrUnset("FLAIR_URL") ?? DEFAULT_URL`). For
42
+ * these, skipping the literal only at flair-mcp's call site was NOT enough:
43
+ * flair-client re-read `process.env` and resurrected the `${...}` literal,
44
+ * defeating its own default. As of flair#1254 flair-client's env fallbacks
45
+ * apply this same literal-as-unset guard themselves, so the strip below is
46
+ * defense-in-depth rather than the only line — kept deliberately (an older
47
+ * flair-client on a consumer's disk does not have #1254).
48
+ *
49
+ * Only FLAIR_URL qualifies today: it is re-read by flair-client AND flair-mcp
50
+ * still constructs a client when it is a literal. FLAIR_AGENT_ID is also re-read
51
+ * by flair-client, but flair-mcp's `if (!agentId)` guard (using readEnvOrUnset)
52
+ * short-circuits before any client is built, so no strip is needed. FLAIR_KEY_PATH
53
+ * flair-client never reads from env, so guarding it at the call site suffices.
54
+ */
55
+ export const ENV_RESURRECTED_BY_FLAIR_CLIENT = ["FLAIR_URL"];
56
+ /**
57
+ * Delete any of `names` whose value is an unsubstituted `${...}` interpolation
58
+ * literal from `env`, so downstream readers — flair-mcp's own reads AND
59
+ * flair-client's internal `process.env` fallback — see the var as unset and
60
+ * apply their default. Idempotent; mutates the live process env by default and
61
+ * accepts an injectable env for tests. Real (substituted) values are untouched.
62
+ */
63
+ export function stripInterpolationLiteralsFromEnv(env = process.env, names = ENV_RESURRECTED_BY_FLAIR_CLIENT) {
64
+ for (const name of names) {
65
+ const v = env[name];
66
+ if (typeof v === "string" && isUnsubstitutedInterpolation(v)) {
67
+ delete env[name];
68
+ }
69
+ }
70
+ }
package/dist/index.js CHANGED
@@ -43,6 +43,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
43
43
  import { FlairClient, FlairError } from "@tpsdev-ai/flair-client";
44
44
  import { z } from "zod";
45
45
  import { deriveActivity, postPresenceSafe, resolveHeartbeatIntervalMs, resolvePresenceTimeoutMs, shouldSendHeartbeat, } from "./presence.js";
46
+ import { readEnvOrUnset, stripInterpolationLiteralsFromEnv } from "./env-guard.js";
46
47
  // ─── Error helpers ──────────────────────────────────────────────────────────
47
48
  function classifyError(err, flairUrl) {
48
49
  if (err instanceof FlairError) {
@@ -137,15 +138,21 @@ export async function runMcp() {
137
138
  process.exit(0);
138
139
  });
139
140
  // ─── Client setup ────────────────────────────────────────────────────────────
140
- const agentId = process.env.FLAIR_AGENT_ID;
141
+ // flair#1250: an MCP host that forwards `"FLAIR_URL": "${FLAIR_URL}"` without
142
+ // substituting hands us the literal `${FLAIR_URL}`. Remove such literals from
143
+ // the process env first, because flair-client's constructor ALSO reads
144
+ // process.env.FLAIR_URL as a fallback — guarding only our own reads below
145
+ // would be silently defeated when flair-client re-reads the raw env.
146
+ stripInterpolationLiteralsFromEnv();
147
+ const agentId = readEnvOrUnset("FLAIR_AGENT_ID");
141
148
  if (!agentId) {
142
149
  console.error("FLAIR_AGENT_ID is required. Set it in your .mcp.json env or shell.");
143
150
  process.exit(1);
144
151
  }
145
152
  const flair = new FlairClient({
146
153
  agentId,
147
- url: process.env.FLAIR_URL,
148
- keyPath: process.env.FLAIR_KEY_PATH,
154
+ url: readEnvOrUnset("FLAIR_URL"),
155
+ keyPath: readEnvOrUnset("FLAIR_KEY_PATH"),
149
156
  // flair#718 authorship-provenance: forward this stdio proxy's own
150
157
  // FLAIR_CLIENT env (set by `flair init`'s per-client wiring, e.g.
151
158
  // "claude-code"/"codex"/"gemini"/"cursor") into the client it constructs
@@ -172,8 +179,8 @@ export async function runMcp() {
172
179
  // whatever timeout the main client is configured with.
173
180
  const presenceFlair = new FlairClient({
174
181
  agentId,
175
- url: process.env.FLAIR_URL,
176
- keyPath: process.env.FLAIR_KEY_PATH,
182
+ url: readEnvOrUnset("FLAIR_URL"),
183
+ keyPath: readEnvOrUnset("FLAIR_KEY_PATH"),
177
184
  timeoutMs: resolvePresenceTimeoutMs(),
178
185
  });
179
186
  // Rate-limit clock + last-known task, in-process only (no persistence —
@@ -76,6 +76,7 @@
76
76
  import { FlairClient } from "@tpsdev-ai/flair-client";
77
77
  import { basename } from "node:path";
78
78
  import { deriveActivity, postPresenceSafe, resolvePresenceTimeoutMs } from "./presence.js";
79
+ import { readEnvOrUnset, stripInterpolationLiteralsFromEnv } from "./env-guard.js";
79
80
  /** Claude Code SessionStart additionalContext hard limit (chars). */
80
81
  const MAX_CHARS = 10_000;
81
82
  /** Token budget for the bootstrap call — matches the proven prototype. */
@@ -148,7 +149,13 @@ function hookOutput(context) {
148
149
  * @param makeClient factory for the bootstrap client (defaults to FlairClient)
149
150
  */
150
151
  export async function runHook(rawInput, makeClient = defaultClientFactory) {
151
- const agentId = process.env.FLAIR_AGENT_ID;
152
+ // flair#1250: drop any unsubstituted `${...}` interpolation literal from the
153
+ // env before the client is built, so flair-client's own process.env fallback
154
+ // (e.g. FLAIR_URL) can't resurrect the literal and defeat its default. See
155
+ // ./env-guard.ts. Runs here (not just at the call site) because the default
156
+ // client factory below reads process.env directly.
157
+ stripInterpolationLiteralsFromEnv();
158
+ const agentId = readEnvOrUnset("FLAIR_AGENT_ID");
152
159
  if (!agentId)
153
160
  return NOOP_OUTPUT; // no identity → no-op, never break the session
154
161
  let input = {};
@@ -200,8 +207,8 @@ export async function runHook(rawInput, makeClient = defaultClientFactory) {
200
207
  function defaultClientFactory(agentId) {
201
208
  return new FlairClient({
202
209
  agentId,
203
- url: process.env.FLAIR_URL,
204
- keyPath: process.env.FLAIR_KEY_PATH,
210
+ url: readEnvOrUnset("FLAIR_URL"),
211
+ keyPath: readEnvOrUnset("FLAIR_KEY_PATH"),
205
212
  });
206
213
  }
207
214
  /** Entry point. Reads stdin, runs the hook, prints the result, exits 0.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair-mcp",
3
- "version": "0.44.12",
3
+ "version": "0.45.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",
@@ -27,7 +27,7 @@
27
27
  },
28
28
  "dependencies": {
29
29
  "@modelcontextprotocol/sdk": "1.27.1",
30
- "@tpsdev-ai/flair-client": "0.44.12",
30
+ "@tpsdev-ai/flair-client": "0.45.0",
31
31
  "zod": "4.3.6"
32
32
  },
33
33
  "license": "Apache-2.0",