@tpsdev-ai/flair 0.41.0 → 0.42.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/config.yaml +19 -90
- package/dist/cli.js +85 -14
- package/dist/install/clients.js +53 -6
- package/dist/lib/mcp-enable.js +133 -14
- package/package.json +1 -1
package/config.yaml
CHANGED
|
@@ -1,100 +1,29 @@
|
|
|
1
1
|
name: flair
|
|
2
2
|
rest: true
|
|
3
|
-
|
|
4
|
-
## Port is configured via CLI (flair init --port) or HTTP_PORT env var.
|
|
5
|
-
## Omitted here to avoid conflicts with different deployment scenarios.
|
|
6
|
-
# http:
|
|
7
|
-
# port: 19926
|
|
8
|
-
|
|
9
|
-
# Harper does not read a component's `.env` implicitly — it only loads env
|
|
10
|
-
# files a component ASKS for, via this plugin. Without this block a `.env`
|
|
11
|
-
# sitting next to config.yaml is inert: the file is present and its values
|
|
12
|
-
# never reach `process.env`. That is exactly what a deployed instance hit —
|
|
13
|
-
# `FLAIR_PUBLIC_URL` was set in the deployed component's `.env` and OAuth
|
|
14
|
-
# discovery kept advertising a loopback issuer (flair#1005, #1000).
|
|
15
|
-
#
|
|
16
|
-
# MUST STAY FIRST. Config keys are iterated in file order by Harper's
|
|
17
|
-
# component loader, and each plugin's initial entry load is awaited before
|
|
18
|
-
# the next key is processed — so declaring this above `jsResource` is what
|
|
19
|
-
# guarantees `process.env` is populated before `dist/resources/*.js` are
|
|
20
|
-
# imported. Most consumers read `process.env` per request and would not care
|
|
21
|
-
# (resources/OAuth.ts, resources/AdminInstance.ts, resources/XAA.ts,
|
|
22
|
-
# resources/a2a-url.ts), but `resources/mcp-oauth.ts` decides at MODULE LOAD
|
|
23
|
-
# whether to mount `/mcp`; move this below `jsResource` and that decision is
|
|
24
|
-
# made against an env that has not been loaded yet.
|
|
25
|
-
#
|
|
26
|
-
# No `.env` is required, which is the case for essentially every local
|
|
27
|
-
# install: when the glob matches nothing the plugin never fires and emits
|
|
28
|
-
# nothing. Measured — a boot log with this block and no `.env` differs from
|
|
29
|
-
# one without the block only in the PID and in non-deterministic table-init
|
|
30
|
-
# ordering. (A MALFORMED declaration is loud, not silent: a pattern
|
|
31
|
-
# containing '..' produced both an `Ignoring invalid loadEnv files pattern`
|
|
32
|
-
# warning and a `Could not load component 'loadEnv'` error, which is the
|
|
33
|
-
# positive control for that silence.)
|
|
34
|
-
#
|
|
35
|
-
# Application variables only. Harper composes its OWN configuration before
|
|
36
|
-
# component `.env` files load, so Harper-level settings cannot be set this
|
|
37
|
-
# way; `HARPER_CONFIG` / `HARPER_DEFAULT_CONFIG` / `HARPER_SET_CONFIG` are
|
|
38
|
-
# refused at the injection point and warned about (harper#1513). Those
|
|
39
|
-
# belong in the process environment or harper-config.yaml.
|
|
40
3
|
loadEnv:
|
|
41
|
-
files:
|
|
42
|
-
|
|
4
|
+
files: .env
|
|
43
5
|
graphqlSchema:
|
|
44
6
|
files: schemas/*.graphql
|
|
45
|
-
|
|
46
7
|
jsResource:
|
|
47
8
|
files: dist/resources/*.js
|
|
48
|
-
|
|
49
|
-
# Phase 1 (flair#504): embeddings now run through Harper's native
|
|
50
|
-
# models.embed() facade, backed by harper-fabric-embeddings registered as the
|
|
51
|
-
# `embedding` backend. That registration is NOT configured here, and (as of
|
|
52
|
-
# flair#694) is no longer config-driven anywhere: a `models:` block in THIS
|
|
53
|
-
# file would silently never be read (this application always loads with
|
|
54
|
-
# `isRoot: false` — components/componentLoader.ts gates `bootstrapModels()`
|
|
55
|
-
# on `isRoot`), and the earlier fix for that — reasserting the block into the
|
|
56
|
-
# Harper INSTANCE-ROOT config via the HARPER_CONFIG env var on every spawn —
|
|
57
|
-
# turned out to PERSIST that block into harper-config.yaml, which an
|
|
58
|
-
# older/downgraded build's boot (never having set the env var) would tear
|
|
59
|
-
# down to an invalid empty shell and refuse to boot against (flair#694; see
|
|
60
|
-
# flair#695 for the invariant this violated). The registration now happens
|
|
61
|
-
# in-process instead: `dist/resources/embeddings-boot.js` (built from
|
|
62
|
-
# resources/embeddings-boot.ts, loaded by the `jsResource` glob below like
|
|
63
|
-
# every other file under resources/) calls harper-fabric-embeddings'
|
|
64
|
-
# `register()` factory directly on every boot — nothing is ever written to
|
|
65
|
-
# the config file, so there is nothing for a downgrade to trip over. This
|
|
66
|
-
# also means a `package:`-style sub-component entry
|
|
67
|
-
# (`'harper-fabric-embeddings': { package: ... }`, which used to sit here and
|
|
68
|
-
# drove `handleApplication`) still must not be reintroduced alongside it —
|
|
69
|
-
# that hook populates a SEPARATE raw-API engine, not `models.embed()`, and
|
|
70
|
-
# running both would double-init (two separate EmbeddingEngine instances:
|
|
71
|
-
# one unused, one backing models.embed).
|
|
72
|
-
|
|
73
9
|
authentication:
|
|
74
|
-
# Default secure (flair#654): a credential-less loopback request to the
|
|
75
|
-
# Harper ops API (:9925) is no longer auto-authorized as super_user. Local
|
|
76
|
-
# admin operations now require a real credential — ~/.flair/admin-pass
|
|
77
|
-
# (written by `flair init`), --admin-pass, or FLAIR_ADMIN_PASS. flair's own
|
|
78
|
-
# application-layer resources were already immune to this forgery (#655's
|
|
79
|
-
# credential-evidence gate); this closes the remaining gap below it, in the
|
|
80
|
-
# raw Harper ops API itself. Set true only for local development, at your
|
|
81
|
-
# own risk.
|
|
82
10
|
authorizeLocal: false
|
|
83
11
|
enableSessions: true
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
12
|
+
'@harperfast/oauth':
|
|
13
|
+
package: '@harperfast/oauth'
|
|
14
|
+
providers:
|
|
15
|
+
github:
|
|
16
|
+
clientId: ${OAUTH_GITHUB_CLIENT_ID}
|
|
17
|
+
clientSecret: ${OAUTH_GITHUB_CLIENT_SECRET}
|
|
18
|
+
mcp:
|
|
19
|
+
enabled: false
|
|
20
|
+
issuer: ${FLAIR_MCP_ISSUER}
|
|
21
|
+
resource: ${FLAIR_MCP_ISSUER}/mcp
|
|
22
|
+
accessTokenTtl: 900
|
|
23
|
+
dynamicClientRegistration:
|
|
24
|
+
enabled: false
|
|
25
|
+
clientIdMetadataDocuments:
|
|
26
|
+
allowedHosts:
|
|
27
|
+
- claude.ai
|
|
28
|
+
- claude.com
|
|
29
|
+
signingKeyPem: ${FLAIR_MCP_SIGNING_KEY_PEM}
|
package/dist/cli.js
CHANGED
|
@@ -19,7 +19,7 @@ import { checkServerHandshake, formatHandshakeNudge, invalidateHandshakeCache }
|
|
|
19
19
|
import { probeInstance } from "./probe.js";
|
|
20
20
|
import { sweepFleet, renderFleetSweepTable, FLEET_EXIT_OK, } from "./fleet-verify.js";
|
|
21
21
|
import { markStale, sortOldestVersionFirst } from "./fleet-presence.js";
|
|
22
|
-
import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor } from "./install/clients.js";
|
|
22
|
+
import { detectClients, renderWiringSummary, wireClaudeCode, wireCodex, wireGemini, wireCursor, clientConfigPath, codexConfigHasFlairSection } from "./install/clients.js";
|
|
23
23
|
import { flairCliVersion, mcpServerSpec, unpinnedSpecWarning } from "./lib/mcp-spec.js";
|
|
24
24
|
import { resolveAgentKeyPath, loadEd25519PrivateKeyFromFile, signClientAssertion, buildTokenRequestForm, getMcpAccessToken, McpTokenRequestError, defaultMcpClientId, defaultMcpTokenEndpoint, defaultMcpResource, defaultMcpIssuer, MAX_ASSERTION_LIFETIME_SECONDS, } from "./mcp-client-assertion.js";
|
|
25
25
|
import { enableMcp, disableMcp, mcpStatus, checkLocalOriginRefusal, selfVerifyMcpMetadata, } from "./lib/mcp-enable.js";
|
|
@@ -1340,11 +1340,9 @@ function b64url(bytes) {
|
|
|
1340
1340
|
* out of scope for this HTTP/REST auth path.
|
|
1341
1341
|
*/
|
|
1342
1342
|
async function api(method, path, body, options) {
|
|
1343
|
-
// Resolve port
|
|
1344
|
-
//
|
|
1345
|
-
const
|
|
1346
|
-
const defaultUrl = savedPort ? `http://127.0.0.1:${savedPort}` : `http://127.0.0.1:${DEFAULT_PORT}`;
|
|
1347
|
-
const base = options?.baseUrl ?? (process.env.FLAIR_URL || defaultUrl);
|
|
1343
|
+
// Resolve port via the canonical path (flair#1129): options.baseUrl > FLAIR_URL > resolveHttpPort.
|
|
1344
|
+
// api() callers mean the default install, so resolveHttpPort({}) with no --data-dir is correct.
|
|
1345
|
+
const base = options?.baseUrl ?? (process.env.FLAIR_URL || `http://127.0.0.1:${resolveHttpPort({})}`);
|
|
1348
1346
|
// Extract agentId from FLAIR_AGENT_ID env, or the body (POST/PUT) / URL
|
|
1349
1347
|
// query params (GET) — Harper-CLI-request-shape knowledge, not a generic
|
|
1350
1348
|
// auth concern, so it stays here rather than in authedRequest.
|
|
@@ -3356,7 +3354,13 @@ program
|
|
|
3356
3354
|
? JSON.parse(readFileSync(claudeJsonPath, "utf-8"))
|
|
3357
3355
|
: {};
|
|
3358
3356
|
const existing = claudeJson.mcpServers?.flair;
|
|
3359
|
-
|
|
3357
|
+
const currentSpec = mcpServerSpec();
|
|
3358
|
+
const existingArgs = existing?.args;
|
|
3359
|
+
const argsMatch = Array.isArray(existingArgs) && existingArgs.includes(currentSpec);
|
|
3360
|
+
const urlAgentMatch = existing && existing.env?.FLAIR_URL === httpUrl && existing.env?.FLAIR_AGENT_ID === agentId;
|
|
3361
|
+
// flair#1135: the pin in `args` must match the current mcpServerSpec().
|
|
3362
|
+
// A matching pin stays a no-op (idempotent); only a stale pin triggers a re-write.
|
|
3363
|
+
if (urlAgentMatch && argsMatch) {
|
|
3360
3364
|
console.log(` ✓ Claude Code already wired in ~/.claude.json`);
|
|
3361
3365
|
wiringResults.push({ client: "claude-code", message: "already wired", wired: true });
|
|
3362
3366
|
}
|
|
@@ -3364,11 +3368,15 @@ program
|
|
|
3364
3368
|
claudeJson.mcpServers = claudeJson.mcpServers || {};
|
|
3365
3369
|
claudeJson.mcpServers.flair = flairMcpConfig;
|
|
3366
3370
|
writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
|
|
3367
|
-
const
|
|
3368
|
-
|
|
3371
|
+
const action = urlAgentMatch ? "refreshed pin in ~/.claude.json"
|
|
3372
|
+
: claudeJsonExisted ? "wired in ~/.claude.json"
|
|
3373
|
+
: "wired in ~/.claude.json (created)";
|
|
3374
|
+
console.log(` ✓ Claude Code ${action} (restart Claude Code to pick it up)`);
|
|
3369
3375
|
wiringResults.push({
|
|
3370
3376
|
client: "claude-code",
|
|
3371
|
-
message:
|
|
3377
|
+
message: urlAgentMatch ? "refreshed pin in ~/.claude.json"
|
|
3378
|
+
: claudeJsonExisted ? "wired ~/.claude.json"
|
|
3379
|
+
: "created and wired ~/.claude.json",
|
|
3372
3380
|
wired: true,
|
|
3373
3381
|
});
|
|
3374
3382
|
}
|
|
@@ -4811,7 +4819,7 @@ mcp
|
|
|
4811
4819
|
const adminPass = dryRun ? (opts.adminPass ?? process.env.FLAIR_ADMIN_PASS ?? "") : resolveLocalAdminPass(opts.adminPass, /* isRemoteTarget */ true);
|
|
4812
4820
|
if (!dryRun && !adminPass) {
|
|
4813
4821
|
console.error("Error: --admin-pass <pass> or --admin-pass-file <path> is required for a REMOTE target " +
|
|
4814
|
-
"(the operations API on the target instance needs it for identity mapping +
|
|
4822
|
+
"(the operations API on the target instance needs it for identity mapping + restart).\n" +
|
|
4815
4823
|
" FLAIR_ADMIN_PASS and ~/.flair/admin-pass are deliberately NOT used here: they are THIS machine's " +
|
|
4816
4824
|
"local admin credentials, and sending them to another instance is how a local secret ends up on someone " +
|
|
4817
4825
|
"else's Harper. Pass the target's own admin password explicitly.");
|
|
@@ -4865,7 +4873,20 @@ mcp
|
|
|
4865
4873
|
process.exit(1);
|
|
4866
4874
|
}
|
|
4867
4875
|
if (!result.ok) {
|
|
4868
|
-
|
|
4876
|
+
if (result.failedStep === "fabric-operator-deploy") {
|
|
4877
|
+
// flair#1136: Fabric deployments require the operator to deploy the
|
|
4878
|
+
// config change — we can't write to harperdb-config.yaml (Fabric
|
|
4879
|
+
// regenerates it on every container restart).
|
|
4880
|
+
console.error(`\n${render.icons.info} ${render.wrap(render.c.bold, "Fabric deployment detected.")}`);
|
|
4881
|
+
console.error(` The @harperfast/oauth block ships in your component config.yaml with mcp.enabled: false.`);
|
|
4882
|
+
console.error(` To activate: set mcp.enabled: true (literal boolean) in your deployed component`);
|
|
4883
|
+
console.error(` config.yaml, ensure the staged secrets are live in the instance's process`);
|
|
4884
|
+
console.error(` environment, and redeploy. Then re-run \`flair mcp enable\` — earlier steps`);
|
|
4885
|
+
console.error(` are idempotent and will be reused.\n`);
|
|
4886
|
+
}
|
|
4887
|
+
else {
|
|
4888
|
+
console.error(`${render.icons.error} enable failed at step "${result.failedStep}" — see detail above for the exact fix, then re-run \`flair mcp enable\` (earlier steps are idempotent and will be reused).`);
|
|
4889
|
+
}
|
|
4869
4890
|
process.exit(1);
|
|
4870
4891
|
}
|
|
4871
4892
|
if (result.dryRun) {
|
|
@@ -9866,6 +9887,55 @@ program
|
|
|
9866
9887
|
authedGet: (path) => verifyAuthedGet(baseUrl, path, defaultKeysDir()),
|
|
9867
9888
|
});
|
|
9868
9889
|
const verdict = decideAfterVerify(verify, previousFlairVersion);
|
|
9890
|
+
// ── Refresh wired MCP client configs (flair#1135) ──────────────────────
|
|
9891
|
+
// After a successful upgrade, the flair-mcp package on disk is newer than
|
|
9892
|
+
// the pinned version in wired client configs. Re-run wiring for
|
|
9893
|
+
// already-wired clients so the pin stays in lockstep with the installed
|
|
9894
|
+
// version. Best-effort: failures warn but never fail the upgrade.
|
|
9895
|
+
const refreshWiredClients = async () => {
|
|
9896
|
+
const agentId = resolveAgentIdOrEnv({}) ?? (() => {
|
|
9897
|
+
try {
|
|
9898
|
+
const keyFiles = readdirSync(defaultKeysDir()).filter((f) => f.endsWith(".key"));
|
|
9899
|
+
return keyFiles.length > 0 ? keyFiles[0].replace(/\.key$/, "") : null;
|
|
9900
|
+
}
|
|
9901
|
+
catch {
|
|
9902
|
+
return null;
|
|
9903
|
+
}
|
|
9904
|
+
})();
|
|
9905
|
+
if (!agentId) {
|
|
9906
|
+
console.log("\n (no agent id known — skip MCP client pin refresh; run `flair init` to refresh manually)");
|
|
9907
|
+
return;
|
|
9908
|
+
}
|
|
9909
|
+
const httpUrl = `http://127.0.0.1:${upgradePort}`;
|
|
9910
|
+
const mcpEnv = { FLAIR_AGENT_ID: agentId, FLAIR_URL: httpUrl };
|
|
9911
|
+
const detected = detectClients().filter(c => c.detected);
|
|
9912
|
+
if (detected.length === 0)
|
|
9913
|
+
return;
|
|
9914
|
+
console.log("\n Refreshing MCP client pins...");
|
|
9915
|
+
for (const client of detected) {
|
|
9916
|
+
const configPath = clientConfigPath(client.id);
|
|
9917
|
+
if (!existsSync(configPath))
|
|
9918
|
+
continue;
|
|
9919
|
+
// Only refresh clients that are already wired — don't wire new ones.
|
|
9920
|
+
let hasFlair = false;
|
|
9921
|
+
try {
|
|
9922
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
9923
|
+
if (client.id === "codex") {
|
|
9924
|
+
hasFlair = codexConfigHasFlairSection(raw);
|
|
9925
|
+
}
|
|
9926
|
+
else {
|
|
9927
|
+
const cfg = JSON.parse(raw);
|
|
9928
|
+
hasFlair = !!cfg.mcpServers?.flair;
|
|
9929
|
+
}
|
|
9930
|
+
}
|
|
9931
|
+
catch { /* unreadable/malformed — skip */ }
|
|
9932
|
+
if (!hasFlair)
|
|
9933
|
+
continue;
|
|
9934
|
+
const env = { ...mcpEnv, FLAIR_CLIENT: client.id };
|
|
9935
|
+
const result = client.wire(env);
|
|
9936
|
+
console.log(` ${result.ok ? "✓" : "•"} ${result.message}`);
|
|
9937
|
+
}
|
|
9938
|
+
};
|
|
9869
9939
|
if (verdict.kind === "ok") {
|
|
9870
9940
|
// flair#1022: the verified facts are unchanged and still stated — the
|
|
9871
9941
|
// upgrade did land. What changes is the MARKER and the claim around it.
|
|
@@ -9881,6 +9951,7 @@ program
|
|
|
9881
9951
|
else
|
|
9882
9952
|
console.log(line);
|
|
9883
9953
|
}
|
|
9954
|
+
await refreshWiredClients();
|
|
9884
9955
|
return;
|
|
9885
9956
|
}
|
|
9886
9957
|
// flair#741 follow-through: a healthy instance the verifier just couldn't
|
|
@@ -9907,6 +9978,7 @@ program
|
|
|
9907
9978
|
console.error(line);
|
|
9908
9979
|
}
|
|
9909
9980
|
}
|
|
9981
|
+
await refreshWiredClients();
|
|
9910
9982
|
return;
|
|
9911
9983
|
}
|
|
9912
9984
|
console.error(`❌ post-restart verification failed: ${verdict.reason}`);
|
|
@@ -15508,8 +15580,7 @@ program
|
|
|
15508
15580
|
if (!dryRun) {
|
|
15509
15581
|
console.log(` Writing to Flair...`);
|
|
15510
15582
|
try {
|
|
15511
|
-
const
|
|
15512
|
-
const httpUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
|
|
15583
|
+
const httpUrl = `http://127.0.0.1:${resolveHttpPort({})}`;
|
|
15513
15584
|
const agentKeyId = `${agentId}.key`;
|
|
15514
15585
|
const keysDir = join(homedir(), ".flair", "keys");
|
|
15515
15586
|
const keyPath = join(keysDir, agentKeyId);
|
package/dist/install/clients.js
CHANGED
|
@@ -165,6 +165,40 @@ export function appendCodexFlairBlock(raw, env) {
|
|
|
165
165
|
const separator = raw.length === 0 ? "" : raw.endsWith("\n\n") ? "" : raw.endsWith("\n") ? "\n" : "\n\n";
|
|
166
166
|
return raw + separator + tomlSnippet(env) + "\n";
|
|
167
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* flair#1135: does the existing `[mcp_servers.flair]` TOML section carry the
|
|
170
|
+
* CURRENT pinned mcpServerSpec()? Pure string scan — no TOML parser needed
|
|
171
|
+
* (same rationale as codexConfigHasFlairSection).
|
|
172
|
+
*/
|
|
173
|
+
function codexFlairSectionHasCurrentPin(raw) {
|
|
174
|
+
const idx = raw.indexOf("[mcp_servers.flair]");
|
|
175
|
+
if (idx === -1)
|
|
176
|
+
return false;
|
|
177
|
+
const after = raw.slice(idx);
|
|
178
|
+
// Find the end of the section: the next top-level [header] that is NOT a
|
|
179
|
+
// sub-table of mcp_servers.flair (e.g. [mcp_servers.flair.env] is part of
|
|
180
|
+
// the same logical section and must not terminate the scan).
|
|
181
|
+
const nextHeader = after.slice("[mcp_servers.flair]".length).search(/\n\[(?!mcp_servers\.flair\.)/);
|
|
182
|
+
const section = nextHeader === -1 ? after : after.slice(0, "[mcp_servers.flair]".length + nextHeader);
|
|
183
|
+
return section.includes(mcpServerSpec());
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* flair#1135: replace the existing `[mcp_servers.flair]` TOML section with a
|
|
187
|
+
* fresh one carrying the current pin. Preserves everything else in the file.
|
|
188
|
+
*/
|
|
189
|
+
function replaceCodexFlairBlock(raw, env) {
|
|
190
|
+
const idx = raw.indexOf("[mcp_servers.flair]");
|
|
191
|
+
if (idx === -1)
|
|
192
|
+
return appendCodexFlairBlock(raw, env);
|
|
193
|
+
const before = raw.slice(0, idx);
|
|
194
|
+
const after = raw.slice(idx);
|
|
195
|
+
const nextHeader = after.slice("[mcp_servers.flair]".length).search(/\n\[(?!mcp_servers\.flair\.)/);
|
|
196
|
+
const rest = nextHeader === -1 ? "" : after.slice("[mcp_servers.flair]".length + nextHeader);
|
|
197
|
+
const newBlock = tomlSnippet(env) + "\n";
|
|
198
|
+
// Preserve the separator between the new block and whatever follows.
|
|
199
|
+
const sep = rest.length === 0 ? "" : rest.startsWith("\n") ? "" : "\n";
|
|
200
|
+
return before + newBlock + sep + rest;
|
|
201
|
+
}
|
|
168
202
|
/**
|
|
169
203
|
* Merge the Flair MCP server into a JSON config file with an `mcpServers` map.
|
|
170
204
|
* Creates the file (and parent dir) if absent; preserves existing servers and
|
|
@@ -182,13 +216,20 @@ function wireJsonMcp(configPath, label, env) {
|
|
|
182
216
|
}
|
|
183
217
|
config.mcpServers = config.mcpServers || {};
|
|
184
218
|
const existing = config.mcpServers.flair;
|
|
185
|
-
|
|
219
|
+
const currentSpec = mcpServerSpec();
|
|
220
|
+
const existingArgs = existing?.args;
|
|
221
|
+
const argsMatch = Array.isArray(existingArgs) && existingArgs.includes(currentSpec);
|
|
222
|
+
const urlAgentMatch = existing && existing.env?.FLAIR_URL === env.FLAIR_URL && existing.env?.FLAIR_AGENT_ID === env.FLAIR_AGENT_ID;
|
|
223
|
+
// flair#1135: the pin in `args` must match the current mcpServerSpec().
|
|
224
|
+
// A matching pin stays a no-op (idempotent); only a stale pin triggers a re-write.
|
|
225
|
+
if (urlAgentMatch && argsMatch) {
|
|
186
226
|
return { ok: true, message: `${label}: already wired in ${display}` };
|
|
187
227
|
}
|
|
188
228
|
config.mcpServers.flair = flairMcpEntry(env);
|
|
189
229
|
mkdirSync(dirname(configPath), { recursive: true });
|
|
190
230
|
writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
191
|
-
|
|
231
|
+
const action = urlAgentMatch ? "refreshed pin in" : "wired";
|
|
232
|
+
return { ok: true, message: `${label}: ${action} ${display} (restart ${label} to pick it up)` };
|
|
192
233
|
}
|
|
193
234
|
catch (err) {
|
|
194
235
|
const reason = err instanceof Error ? err.message : String(err);
|
|
@@ -250,17 +291,23 @@ function _wireCodex(env) {
|
|
|
250
291
|
// parser, but appending a new top-level table at EOF is safe TOML when the
|
|
251
292
|
// exact header isn't already present (flair#727) — so an existing file only
|
|
252
293
|
// forces the manual-print fallback when it's genuinely unreadable/
|
|
253
|
-
// unwritable (permissions, I/O error), never merely "exists".
|
|
254
|
-
//
|
|
255
|
-
//
|
|
294
|
+
// unwritable (permissions, I/O error), never merely "exists".
|
|
295
|
+
//
|
|
296
|
+
// flair#1135: the "already wired" check is now version-aware — a section
|
|
297
|
+
// with a stale pin triggers a re-write instead of a no-op.
|
|
256
298
|
const path = codexConfigPath();
|
|
257
299
|
const display = "~/.codex/config.toml";
|
|
258
300
|
try {
|
|
259
301
|
if (existsSync(path)) {
|
|
260
302
|
const raw = readFileSync(path, "utf-8");
|
|
261
|
-
if (
|
|
303
|
+
if (codexFlairSectionHasCurrentPin(raw)) {
|
|
262
304
|
return { ok: true, message: `Codex: already wired in ${display}` };
|
|
263
305
|
}
|
|
306
|
+
if (codexConfigHasFlairSection(raw)) {
|
|
307
|
+
// Section exists but pin is stale — replace it.
|
|
308
|
+
writeFileSync(path, replaceCodexFlairBlock(raw, env));
|
|
309
|
+
return { ok: true, message: `Codex: refreshed pin in ${display} (restart Codex to pick it up)` };
|
|
310
|
+
}
|
|
264
311
|
writeFileSync(path, appendCodexFlairBlock(raw, env));
|
|
265
312
|
return { ok: true, message: `Codex: wired ${display} (restart Codex to pick it up)` };
|
|
266
313
|
}
|
package/dist/lib/mcp-enable.js
CHANGED
|
@@ -144,6 +144,7 @@ import { existsSync, mkdirSync, writeFileSync, chmodSync, readFileSync } from "n
|
|
|
144
144
|
import { homedir } from "node:os";
|
|
145
145
|
import { join, dirname } from "node:path";
|
|
146
146
|
import { generateKeyPairSync, randomBytes } from "node:crypto";
|
|
147
|
+
import yaml from "js-yaml";
|
|
147
148
|
// ─── CIMD constants ──────────────────────────────────────────────────────────
|
|
148
149
|
/** Default `clientIdMetadataDocuments.allowedHosts` allowlist — see the
|
|
149
150
|
* module header's "claude.ai CIMD/redirect-URI allowlist hosts" note for
|
|
@@ -303,6 +304,7 @@ export function buildMcpOAuthConfigBlock(params) {
|
|
|
303
304
|
const provider = params.idpProvider;
|
|
304
305
|
const envPrefix = `OAUTH_${provider.toUpperCase()}`;
|
|
305
306
|
const cimdAllowedHosts = params.cimdAllowedHosts ?? DEFAULT_CIMD_ALLOWED_HOSTS;
|
|
307
|
+
const enabled = params.enabled ?? true;
|
|
306
308
|
return {
|
|
307
309
|
"@harperfast/oauth": {
|
|
308
310
|
package: "@harperfast/oauth",
|
|
@@ -313,7 +315,7 @@ export function buildMcpOAuthConfigBlock(params) {
|
|
|
313
315
|
},
|
|
314
316
|
},
|
|
315
317
|
mcp: {
|
|
316
|
-
enabled
|
|
318
|
+
enabled,
|
|
317
319
|
issuer: "${FLAIR_MCP_ISSUER}",
|
|
318
320
|
resource: "${FLAIR_MCP_ISSUER}/mcp",
|
|
319
321
|
accessTokenTtl: REQUIRED_ACCESS_TOKEN_TTL,
|
|
@@ -331,6 +333,85 @@ export function buildMcpOAuthConfigBlock(params) {
|
|
|
331
333
|
},
|
|
332
334
|
};
|
|
333
335
|
}
|
|
336
|
+
// ─── Local config.yaml update (flair#1136) ──────────────────────────────────
|
|
337
|
+
/**
|
|
338
|
+
* Flip mcp.enabled in a local component config.yaml. Best-effort: returns
|
|
339
|
+
* `{ ok: false }` with a reason when the file can't be found or parsed.
|
|
340
|
+
*
|
|
341
|
+
* Looks for config.yaml at `explicitPath`, then `./config.yaml`, then
|
|
342
|
+
* `~/.flair/config.yaml`. When found, replaces `mcp:\n enabled: false`
|
|
343
|
+
* with `mcp:\n enabled: true` (exact string match — avoids a YAML parser
|
|
344
|
+
* dependency for a single boolean flip).
|
|
345
|
+
*/
|
|
346
|
+
export function updateLocalConfigMcpEnabled(enabled, explicitPath) {
|
|
347
|
+
const candidates = explicitPath
|
|
348
|
+
? [explicitPath]
|
|
349
|
+
: ["config.yaml", join(homedir(), ".flair", "config.yaml")];
|
|
350
|
+
let configPath = null;
|
|
351
|
+
for (const p of candidates) {
|
|
352
|
+
if (existsSync(p)) {
|
|
353
|
+
configPath = p;
|
|
354
|
+
break;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
if (!configPath) {
|
|
358
|
+
return {
|
|
359
|
+
ok: false,
|
|
360
|
+
detail: `local config.yaml not found (tried: ${candidates.join(", ")}). ` +
|
|
361
|
+
`Set mcp.enabled: ${enabled} in your component config.yaml manually, then restart.`,
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
let raw;
|
|
365
|
+
try {
|
|
366
|
+
raw = readFileSync(configPath, "utf-8");
|
|
367
|
+
}
|
|
368
|
+
catch (err) {
|
|
369
|
+
return { ok: false, detail: `cannot read ${configPath}: ${err.message}` };
|
|
370
|
+
}
|
|
371
|
+
// Parse the YAML to navigate to the exact key — avoids the ambiguity of
|
|
372
|
+
// string-matching `enabled:` when the block has multiple enabled keys
|
|
373
|
+
// (mcp.enabled vs dynamicClientRegistration.enabled).
|
|
374
|
+
let doc;
|
|
375
|
+
try {
|
|
376
|
+
doc = yaml.load(raw);
|
|
377
|
+
}
|
|
378
|
+
catch (err) {
|
|
379
|
+
return { ok: false, detail: `cannot parse ${configPath} as YAML: ${err.message}` };
|
|
380
|
+
}
|
|
381
|
+
if (!doc || typeof doc !== "object") {
|
|
382
|
+
return { ok: false, detail: `${configPath} is empty or not a YAML mapping` };
|
|
383
|
+
}
|
|
384
|
+
const oauth = doc["@harperfast/oauth"];
|
|
385
|
+
if (!oauth || typeof oauth !== "object") {
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
detail: `@harperfast/oauth block not found in ${configPath}. ` +
|
|
389
|
+
`Ensure the component block is present with mcp.enabled: ${enabled}.`,
|
|
390
|
+
};
|
|
391
|
+
}
|
|
392
|
+
const mcp = oauth.mcp;
|
|
393
|
+
if (!mcp || typeof mcp !== "object") {
|
|
394
|
+
return {
|
|
395
|
+
ok: false,
|
|
396
|
+
detail: `mcp key not found under @harperfast/oauth in ${configPath}. ` +
|
|
397
|
+
`Ensure the mcp block is present with enabled: ${enabled}.`,
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
const current = mcp.enabled;
|
|
401
|
+
if (current === enabled) {
|
|
402
|
+
return { ok: true, detail: `mcp.enabled already ${enabled} in ${configPath}` };
|
|
403
|
+
}
|
|
404
|
+
// Mutate the parsed document and re-emit.
|
|
405
|
+
mcp.enabled = enabled;
|
|
406
|
+
const updated = yaml.dump(doc, { lineWidth: -1, noCompatMode: true });
|
|
407
|
+
try {
|
|
408
|
+
writeFileSync(configPath, updated, { encoding: "utf-8" });
|
|
409
|
+
}
|
|
410
|
+
catch (err) {
|
|
411
|
+
return { ok: false, detail: `cannot write ${configPath}: ${err.message}` };
|
|
412
|
+
}
|
|
413
|
+
return { ok: true, detail: `mcp.enabled set to ${enabled} in ${configPath}` };
|
|
414
|
+
}
|
|
334
415
|
/** The exact callback URL to hand the operator when they create the IdP
|
|
335
416
|
* OAuth app ("with the exact GitHub callback URL printed"). */
|
|
336
417
|
export function idpCallbackUrl(issuer, idpProvider) {
|
|
@@ -871,11 +952,13 @@ export async function enableMcp(params, deps = {}) {
|
|
|
871
952
|
currentStep = "signing-key";
|
|
872
953
|
const keyResult = ensureSigningKeyFile(params.signingKeyFilePath, { generate: deps.generateRsaKeyPair });
|
|
873
954
|
push(true, `signing key ${keyResult.reused ? "reused" : "generated"} at ${keyResult.path} (0600)`);
|
|
874
|
-
// ── @harperfast/oauth config
|
|
955
|
+
// ── @harperfast/oauth config (flair#1136: shipped in config.yaml) ──────
|
|
956
|
+
// The block ships uncommented with mcp.enabled: false (inert default).
|
|
957
|
+
// set_configuration is removed — the block lives in the component's own
|
|
958
|
+
// config.yaml, not in harperdb-config.yaml where Fabric would wipe it.
|
|
875
959
|
const cimdAllowedHosts = params.cimdAllowedHosts ?? DEFAULT_CIMD_ALLOWED_HOSTS;
|
|
876
960
|
currentStep = "config-block";
|
|
877
|
-
|
|
878
|
-
push(true, `built the @harperfast/oauth mcp config block (accessTokenTtl=${REQUIRED_ACCESS_TOKEN_TTL}, ` +
|
|
961
|
+
push(true, `@harperfast/oauth config ships in config.yaml (mcp.enabled=false, ` +
|
|
879
962
|
`dynamicClientRegistration.enabled=false, clientIdMetadataDocuments.allowedHosts=${JSON.stringify(cimdAllowedHosts)})`);
|
|
880
963
|
// ── IdP OAuth-app credential intake ───────────────────────────────────────
|
|
881
964
|
currentStep = "idp-credentials";
|
|
@@ -973,18 +1056,54 @@ export async function enableMcp(params, deps = {}) {
|
|
|
973
1056
|
}
|
|
974
1057
|
if (!confirmed) {
|
|
975
1058
|
push(false, `not applied: pass --confirm-secrets-applied once the staged secrets are live on ${params.instance}, then re-run \`flair mcp enable\` (earlier steps are idempotent and will reuse what's already provisioned).`);
|
|
976
|
-
return { ok: false, dryRun, steps, failedStep: "
|
|
1059
|
+
return { ok: false, dryRun, steps, failedStep: "secrets-provisioning", secretsMechanism: secretsResult.mechanism, secretsPath: secretsResult.path };
|
|
1060
|
+
}
|
|
1061
|
+
// ── flair#1136: config delivery is now SHIPPED in config.yaml ────────────
|
|
1062
|
+
// The @harperfast/oauth block ships uncommented with mcp.enabled: false
|
|
1063
|
+
// (inert default). set_configuration is REMOVED — Fabric regenerates
|
|
1064
|
+
// harperdb-config.yaml on every container restart, so writing the block
|
|
1065
|
+
// there was always a race against the next deploy. Instead:
|
|
1066
|
+
//
|
|
1067
|
+
// - Standalone-local: flip mcp.enabled to true in the local config.yaml,
|
|
1068
|
+
// restart, self-verify.
|
|
1069
|
+
// - Fabric: the operator must set mcp.enabled: true in their deployed
|
|
1070
|
+
// component config.yaml. Report the requirement LOUDLY — never report
|
|
1071
|
+
// success with /mcp still dark.
|
|
1072
|
+
const isFabric = isFabricOrigin(params.instance);
|
|
1073
|
+
if (isFabric) {
|
|
1074
|
+
// ── Fabric: operator-deploy requirement ──────────────────────────────
|
|
1075
|
+
currentStep = "fabric-operator-deploy";
|
|
1076
|
+
const msg = [
|
|
1077
|
+
`Fabric deployment detected (${new URL(params.instance).hostname}).`,
|
|
1078
|
+
`The @harperfast/oauth block ships in config.yaml with mcp.enabled: false.`,
|
|
1079
|
+
`To activate: set mcp.enabled: true (literal boolean) in your deployed component config.yaml,`,
|
|
1080
|
+
`ensure the staged secrets are live in the instance's process environment, and redeploy.`,
|
|
1081
|
+
`Then re-run \`flair mcp enable\` — earlier steps are idempotent and will be reused.`,
|
|
1082
|
+
].join(" ");
|
|
1083
|
+
push(false, msg);
|
|
1084
|
+
return {
|
|
1085
|
+
ok: false,
|
|
1086
|
+
dryRun,
|
|
1087
|
+
steps,
|
|
1088
|
+
failedStep: "fabric-operator-deploy",
|
|
1089
|
+
issuer,
|
|
1090
|
+
resource: `${issuer}/mcp`,
|
|
1091
|
+
secretsMechanism: secretsResult.mechanism,
|
|
1092
|
+
secretsPath: secretsResult.path,
|
|
1093
|
+
signingKeyFilePath: keyResult.path,
|
|
1094
|
+
callbackUrl,
|
|
1095
|
+
};
|
|
977
1096
|
}
|
|
978
|
-
|
|
979
|
-
|
|
1097
|
+
// ── Standalone (non-Fabric): update local config + restart ────────────
|
|
1098
|
+
currentStep = "local-config-update";
|
|
1099
|
+
const localConfigResult = updateLocalConfigMcpEnabled(true, params.localConfigPath);
|
|
1100
|
+
push(localConfigResult.ok, localConfigResult.detail);
|
|
1101
|
+
// ── Restart ───────────────────────────────────────────────────────────
|
|
1102
|
+
currentStep = "restart";
|
|
980
1103
|
const preDiscriminator = await captureBootDiscriminator(params.instance, params.adminUser, params.adminPass, { fetchImpl: deps.fetchImpl });
|
|
981
|
-
await
|
|
982
|
-
push(true, `
|
|
1104
|
+
await triggerRemoteRestart(params.instance, params.adminUser, params.adminPass, { fetchImpl: deps.fetchImpl });
|
|
1105
|
+
push(true, `restart triggered against ${params.instance}`);
|
|
983
1106
|
// ── Verify the process actually restarted (flair#1120) ──────────────────
|
|
984
|
-
// Poll the ops API until the PID changes — the old process can briefly
|
|
985
|
-
// still answer after a real restart, so a single post-capture is unreliable.
|
|
986
|
-
// waitForOpsApi guarantees PID change (or throws on timeout), so the restart
|
|
987
|
-
// is confirmed when this call returns.
|
|
988
1107
|
currentStep = "verify-restart";
|
|
989
1108
|
const postDiscriminator = await waitForOpsApi(resolveOpsUrl(params.instance), basicAuthHeader(params.adminUser, params.adminPass), preDiscriminator.pid, {
|
|
990
1109
|
fetchImpl: deps.fetchImpl,
|
|
@@ -996,7 +1115,7 @@ export async function enableMcp(params, deps = {}) {
|
|
|
996
1115
|
currentStep = "self-verify";
|
|
997
1116
|
const verify = await selfVerifyMcpMetadata(issuer, { fetchImpl: deps.fetchImpl });
|
|
998
1117
|
if (!verify.ok) {
|
|
999
|
-
push(false, `${verify.detail} — re-run \`flair mcp status\` to check current state, or \`flair mcp enable\` to retry
|
|
1118
|
+
push(false, `${verify.detail} — re-run \`flair mcp status\` to check current state, or \`flair mcp enable\` to retry.`);
|
|
1000
1119
|
return {
|
|
1001
1120
|
ok: false,
|
|
1002
1121
|
dryRun,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tpsdev-ai/flair",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.42.0",
|
|
4
4
|
"packageManager": "bun@1.3.10",
|
|
5
5
|
"description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
|
|
6
6
|
"type": "module",
|