@tpsdev-ai/flair 0.16.0 → 0.17.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/dist/cli.js CHANGED
@@ -1158,6 +1158,17 @@ export function probeOpenclawPluginVersion(extensionName) {
1158
1158
  return null;
1159
1159
  }
1160
1160
  }
1161
+ /**
1162
+ * Whether a package's status line should be printed in the default `flair
1163
+ * upgrade` listing. Suppresses optional-because-openclaw-is-absent lines
1164
+ * (ops-p42n) — pure noise on machines without openclaw — unless `--all`
1165
+ * (showAll) is set. All other statuses always print.
1166
+ */
1167
+ export function shouldPrintUpgradeLine(status, showAll) {
1168
+ if (status === "optional" && !showAll)
1169
+ return false;
1170
+ return true;
1171
+ }
1161
1172
  /**
1162
1173
  * Order a soul key→count map for display: highest count first, ties broken
1163
1174
  * alphabetically for stable output. Soul entries are keyed identity facts
@@ -5871,7 +5882,13 @@ program
5871
5882
  {
5872
5883
  name: "@tpsdev-ai/flair-mcp",
5873
5884
  kind: "bin",
5874
- probe: () => probeBinVersion(execFileSync, "flair-mcp"),
5885
+ // Older flair-mcp installs (e.g. 0.10.0) either aren't on PATH or
5886
+ // don't support `--version`, so the bin probe returns null even when
5887
+ // the package IS globally installed (ops-p42n). Fall back to the lib
5888
+ // probe, which require.resolves the package.json from a sibling global
5889
+ // install regardless of PATH or --version support. kind stays "bin" so
5890
+ // it remains npm-upgradeable (npm install -g), not the openclaw path.
5891
+ probe: () => probeBinVersion(execFileSync, "flair-mcp") ?? probeLibVersion("@tpsdev-ai/flair-mcp"),
5875
5892
  },
5876
5893
  {
5877
5894
  name: "@tpsdev-ai/openclaw-flair",
@@ -5909,6 +5926,13 @@ program
5909
5926
  status = "outdated";
5910
5927
  }
5911
5928
  findings.push({ name, installed, latest, status, kind });
5929
+ // Suppress the line for openclaw plugins that are optional-because-
5930
+ // openclaw-is-absent (ops-p42n): on machines without openclaw the
5931
+ // "○ … not installed (openclaw not detected) → … (install via …)"
5932
+ // line is pure noise. Still print it when openclaw IS installed
5933
+ // (current/outdated) or under --all.
5934
+ if (!shouldPrintUpgradeLine(status, showAll))
5935
+ continue;
5912
5936
  const icon = status === "current" ? "✅"
5913
5937
  : status === "outdated" ? "⬆️"
5914
5938
  : status === "optional" ? "○"
@@ -5922,6 +5946,9 @@ program
5922
5946
  }
5923
5947
  catch { /* skip unavailable packages */ }
5924
5948
  }
5949
+ // Scope footer (ops-p42n): make explicit what `flair upgrade` does and
5950
+ // doesn't cover, so "were the others checked?" has a one-line answer.
5951
+ console.log("\nScope: npm-global packages (flair, flair-mcp) + openclaw plugins. Other integrations (pi-flair, langgraph-flair, n8n-nodes-flair, hermes-flair) upgrade in their own ecosystems (pi / pip / n8n).");
5925
5952
  const outdated = findings.filter((f) => f.status === "outdated");
5926
5953
  const missing = findings.filter((f) => f.status === "missing");
5927
5954
  // openclaw plugins upgrade through `openclaw plugins install`, not `npm
@@ -6009,7 +6036,7 @@ program
6009
6036
  }
6010
6037
  }
6011
6038
  else {
6012
- console.log("\nRun: flair restart to use the new version");
6039
+ console.log("\nRun: flair restart to use the new version");
6013
6040
  }
6014
6041
  });
6015
6042
  // ─── flair stop ───────────────────────────────────────────────────────────────
@@ -22,7 +22,12 @@ import { layout, htmlResponse, esc } from "./admin-layout.js";
22
22
  */
23
23
  export class AdminMemory extends Resource {
24
24
  async get() {
25
- const request = this.request;
25
+ // Harper v5 does not populate this.request on Resource subclasses —
26
+ // getContext() is the only reliable path (ops-sal4: the previous
27
+ // `(this as any).request` read was always undefined, so query params
28
+ // (?id=, ?q=, ?subject=, ?limit=) were always ignored).
29
+ const ctx = this.getContext?.();
30
+ const request = ctx?.request ?? ctx;
26
31
  const url = new URL(request?.url ?? "http://localhost", "http://localhost");
27
32
  const id = url.searchParams.get("id") ?? "";
28
33
  if (id) {
@@ -39,7 +39,14 @@ export class AgentSeed extends Resource {
39
39
  return allowAdmin(this.getContext?.());
40
40
  }
41
41
  async post(data) {
42
- const actorId = this.request?.tpsAgent;
42
+ // Harper v5 does not populate this.request on Resource subclasses —
43
+ // getContext() is the only reliable path (ops-sal4: the previous
44
+ // `(this as any).request` read was always undefined, so actorId was always
45
+ // undefined and this belt-and-suspenders check fail-closed every request,
46
+ // even from a real admin already verified by allowCreate()).
47
+ const ctx = this.getContext?.();
48
+ const request = ctx?.request ?? ctx;
49
+ const actorId = request?.tpsAgent;
43
50
  if (!actorId || !(await isAdmin(actorId))) {
44
51
  return new Response(JSON.stringify({ error: "forbidden: admin only" }), { status: 403 });
45
52
  }
@@ -63,7 +63,13 @@ export class IngestEvents extends Resource {
63
63
  return true;
64
64
  }
65
65
  async post(body, context) {
66
- const request = this.request;
66
+ // Harper v5 does not populate this.request on Resource subclasses —
67
+ // getContext() is the only reliable path (ops-sal4: the previous
68
+ // `(this as any).request` read was always undefined, so authHeader was
69
+ // always undefined and every request 401'd before reaching the office
70
+ // Ed25519 signature check below).
71
+ const ctx = this.getContext?.();
72
+ const request = ctx?.request ?? ctx;
67
73
  const authHeader = request?.headers?.get?.("authorization") ?? request?.headers?.authorization;
68
74
  // Parse and validate body
69
75
  let payload;
@@ -2,6 +2,7 @@ import { Resource, databases } from "@harperfast/harper";
2
2
  import { allowVerified } from "./agent-auth.js";
3
3
  import { getEmbedding } from "./embeddings-provider.js";
4
4
  import { wrapUntrusted } from "./content-safety.js";
5
+ import { isTeammate, formatTeamLine } from "./memory-bootstrap-lib.js";
5
6
  /**
6
7
  * POST /MemoryBootstrap
7
8
  *
@@ -13,6 +14,9 @@ import { wrapUntrusted } from "./content-safety.js";
13
14
  * 4. Task-relevant memories (semantic search if currentTask provided)
14
15
  * 5. Relationship context (active relationships for mentioned entities)
15
16
  * 6. Predicted context (based on channel/surface/subject hints)
17
+ * 7. Team roster (other active agents in this office + a search-first nudge —
18
+ * bootstrap only ever loads the caller's own memories, so this is the one
19
+ * place agents learn teammates' findings exist without a separate call)
16
20
  *
17
21
  * Prediction: when context signals (channel, surface, subjects) are provided,
18
22
  * the bootstrap loads more aggressively — Flair is fast enough that the
@@ -80,6 +84,7 @@ export class BootstrapMemories extends Resource {
80
84
  const sections = {
81
85
  soul: [],
82
86
  skills: [],
87
+ team: [],
83
88
  permanent: [],
84
89
  recent: [],
85
90
  predicted: [],
@@ -172,6 +177,32 @@ export class BootstrapMemories extends Resource {
172
177
  sections.skills.push(line);
173
178
  }
174
179
  }
180
+ // --- 1c. Team roster + cross-agent search nudge ---
181
+ // Bootstrap only ever loads the caller's OWN soul/memories (every query
182
+ // below filters record.agentId === agentId). Nothing here tells the agent
183
+ // that teammates exist or that their findings are one memory_search away
184
+ // — so agents never think to check before re-investigating something a
185
+ // teammate already solved. This section is fixed-cost (no query text to
186
+ // format per agent) so it's cheap enough to always include, not budgeted.
187
+ //
188
+ // Permissive kind/status checks are DELIBERATE: Agent.ts registration
189
+ // defaults both (`kind ||= "agent"`, `status ||= "active"`), so pre-1.0
190
+ // records missing either field are legacy agents/active — a strict
191
+ // `!== "agent"` check would silently drop them. Assumes single-tenant
192
+ // (one instance = one office); grant-filtered roster is the multi-tenant follow-up.
193
+ try {
194
+ const teammateIds = [];
195
+ for await (const record of databases.flair.Agent.search()) {
196
+ if (isTeammate(record, agentId))
197
+ teammateIds.push(record.id);
198
+ }
199
+ const line = formatTeamLine(teammateIds);
200
+ if (line)
201
+ sections.team.push(line);
202
+ }
203
+ catch {
204
+ // Agent table may not exist in older / standalone deployments
205
+ }
175
206
  // --- 2. Permanent memories (always included, highest priority) ---
176
207
  const allMemories = [];
177
208
  for await (const record of databases.flair.Memory.search()) {
@@ -379,6 +410,9 @@ export class BootstrapMemories extends Resource {
379
410
  if (sections.skills.length > 0) {
380
411
  parts.push("## Active Skills\n" + sections.skills.join("\n"));
381
412
  }
413
+ if (sections.team.length > 0) {
414
+ parts.push("## Team\n" + sections.team.join("\n"));
415
+ }
382
416
  if (sections.permanent.length > 0) {
383
417
  parts.push("## Core Principles\n" + sections.permanent.join("\n"));
384
418
  }
@@ -405,6 +439,7 @@ export class BootstrapMemories extends Resource {
405
439
  sections: {
406
440
  soul: sections.soul.length,
407
441
  skills: sections.skills.length,
442
+ team: sections.team.length,
408
443
  permanent: sections.permanent.length,
409
444
  recent: sections.recent.length,
410
445
  predicted: sections.predicted.length,
@@ -1,6 +1,7 @@
1
1
  import { Resource, databases } from "@harperfast/harper";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import { handleJwtBearerGrant } from "./XAA.js";
4
+ import { resolveAgentAuth } from "./agent-auth.js";
4
5
  /**
5
6
  * OAuth 2.1 Authorization Server for Flair.
6
7
  *
@@ -121,7 +122,12 @@ export class OAuthAuthorize extends Resource {
121
122
  async get() {
122
123
  // In 1.0, this returns a simple HTML consent page.
123
124
  // The user (Nathan) approves or denies, which POSTs back.
124
- const request = this.request;
125
+ // Harper v5 does not populate this.request on Resource subclasses —
126
+ // getContext() is the only reliable path (ops-sal4: the previous
127
+ // `(this as any).request` read was always undefined, so query params were
128
+ // always empty).
129
+ const ctx = this.getContext?.();
130
+ const request = ctx?.request ?? ctx;
125
131
  const url = new URL(request?.url ?? "http://localhost", "http://localhost");
126
132
  const clientId = url.searchParams.get("client_id") ?? "";
127
133
  const redirectUri = url.searchParams.get("redirect_uri") ?? "";
@@ -193,9 +199,29 @@ ${scope.split(" ").map((s) => `<div class="scope">${s}</div>`).join("")}
193
199
  const params = new URLSearchParams({ error: "access_denied", state });
194
200
  return Response.redirect(`${redirectUri}?${params}`, 302);
195
201
  }
196
- // Determine authenticated principal
197
- const request = this.request;
198
- const principalId = request?.tpsAgent ?? "admin";
202
+ // Determine authenticated principal. Harper v5 does not populate
203
+ // this.request on Resource subclasses, so `(this as any).request?.tpsAgent`
204
+ // was always undefined and this ALWAYS fell back to the hardcoded "admin"
205
+ // every approved consent grant was minted for the admin principal
206
+ // regardless of who actually approved it (ops-sal4 identity spoof).
207
+ // resolveAgentAuth(getContext()) resolves Basic super_user/admin auth to
208
+ // { kind: "agent", agentId: username, isAdmin: true } (see agent-auth.ts).
209
+ // We do NOT silently fall back to "admin" on an unresolved principal — if
210
+ // no principal can be resolved, that's an error state, not an admin grant.
211
+ //
212
+ // SECURITY REVIEW (Sherlock): is resolving the approving principal via
213
+ // resolveAgentAuth the correct policy for "who may approve OAuth consent"
214
+ // in 1.0 — i.e. must the approver be Basic-authenticated as super_user/
215
+ // admin, or should any verified (non-admin) agent be able to approve its
216
+ // own consent grant? The previous code always resolved to "admin" for
217
+ // every caller, so this is a genuine policy decision, not just a bug fix.
218
+ const auth = await resolveAgentAuth(this.getContext?.());
219
+ if (auth.kind !== "agent") {
220
+ return new Response(JSON.stringify({ error: "authentication required" }), {
221
+ status: 401, headers: { "content-type": "application/json" },
222
+ });
223
+ }
224
+ const principalId = auth.agentId;
199
225
  // Generate authorization code
200
226
  const code = randomBytes(32).toString("base64url");
201
227
  const now = nowISO();
@@ -10,7 +10,7 @@
10
10
  * Limit 50 events max.
11
11
  */
12
12
  import { Resource, databases } from "@harperfast/harper";
13
- import { allowVerified } from "./agent-auth.js";
13
+ import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
14
14
  export class OrgEventCatchup extends Resource {
15
15
  // Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
16
16
  // admin elevation). Any verified agent may catch up; participant scoping is in
@@ -21,9 +21,12 @@ export class OrgEventCatchup extends Resource {
21
21
  }
22
22
  // HarperDB calls get(pathInfo, context) where pathInfo is the URL segment after /OrgEventCatchup/
23
23
  async get(pathInfo) {
24
- const request = this.request;
25
- const callerAgent = request?.tpsAgent;
26
- const callerIsAdmin = request?.tpsAgentIsAdmin === true;
24
+ // Harper v5 does not populate this.request on Resource subclasses —
25
+ // getContext() is the only reliable path to the gate's tpsAgent/
26
+ // tpsAgentIsAdmin annotations (ops-sal4: the previous `(this as
27
+ // any).request` read was always undefined, so the ownership check below
28
+ // never ran — fail-open cross-agent read).
29
+ const auth = await resolveAgentAuth(this.getContext?.());
27
30
  // Harper routes /OrgEventCatchup/{id} with pathInfo.id as the path segment
28
31
  const participantId = (typeof pathInfo === "object" && pathInfo !== null ? pathInfo.id : null) ??
29
32
  (typeof pathInfo === "string" ? pathInfo : null) ??
@@ -32,8 +35,13 @@ export class OrgEventCatchup extends Resource {
32
35
  if (!participantId) {
33
36
  return new Response(JSON.stringify({ error: "participantId required in path: GET /OrgEventCatchup/{participantId}" }), { status: 400, headers: { "Content-Type": "application/json" } });
34
37
  }
35
- // Auth: requesting agent must match participantId (or admin)
36
- if (callerAgent && !callerIsAdmin && callerAgent !== participantId) {
38
+ // Auth: internal calls and admins pass unfiltered; a verified agent may only
39
+ // fetch its own catchup feed; anonymous is denied. allowRead() already
40
+ // blocks anonymous HTTP, but this handler must fail closed on its own too.
41
+ if (auth.kind === "anonymous") {
42
+ return new Response(JSON.stringify({ error: "forbidden: can only fetch events for yourself" }), { status: 403, headers: { "Content-Type": "application/json" } });
43
+ }
44
+ if (auth.kind === "agent" && !auth.isAdmin && auth.agentId !== participantId) {
37
45
  return new Response(JSON.stringify({ error: "forbidden: can only fetch events for yourself" }), { status: 403, headers: { "Content-Type": "application/json" } });
38
46
  }
39
47
  // Harper parses query params into pathInfo.conditions array:
@@ -5,7 +5,7 @@
5
5
  * Auth: requesting agent must match agentId in path (or be admin).
6
6
  */
7
7
  import { Resource, databases } from "@harperfast/harper";
8
- import { allowVerified } from "./agent-auth.js";
8
+ import { allowVerified, resolveAgentAuth } from "./agent-auth.js";
9
9
  export class WorkspaceLatest extends Resource {
10
10
  // Self-authorize via the Ed25519 agent verify (auth reshape removes the gate's
11
11
  // admin elevation). Any verified agent may read; the path-vs-agent ownership
@@ -14,9 +14,13 @@ export class WorkspaceLatest extends Resource {
14
14
  return allowVerified(this.getContext?.());
15
15
  }
16
16
  async get(pathInfo) {
17
- const request = this.context?.request ?? this.request;
18
- const callerAgent = request?.tpsAgent;
19
- const callerIsAdmin = request?.tpsAgentIsAdmin === true;
17
+ // Harper v5 does not populate this.context / this.request on Resource
18
+ // subclasses getContext() is the only reliable path to the gate's
19
+ // tpsAgent/tpsAgentIsAdmin annotations (ops-sal4: the previous
20
+ // `(this as any).context?.request ?? (this as any).request` read was always
21
+ // undefined, so the ownership check below never ran — fail-open cross-agent
22
+ // read).
23
+ const auth = await resolveAgentAuth(this.getContext?.());
20
24
  // Extract agentId from path: /WorkspaceLatest/{agentId}
21
25
  const agentId = (typeof pathInfo === "string" ? pathInfo : null) ??
22
26
  this.getId?.() ??
@@ -24,8 +28,13 @@ export class WorkspaceLatest extends Resource {
24
28
  if (!agentId) {
25
29
  return new Response(JSON.stringify({ error: "agentId required in path: GET /WorkspaceLatest/{agentId}" }), { status: 400, headers: { "Content-Type": "application/json" } });
26
30
  }
27
- // Auth: requesting agent must match path agentId (or admin)
28
- if (callerAgent && !callerIsAdmin && callerAgent !== agentId) {
31
+ // Auth: internal calls and admins pass unfiltered; a verified agent may only
32
+ // read its own workspace state; anonymous is denied. allowRead() already
33
+ // blocks anonymous HTTP, but this handler must fail closed on its own too.
34
+ if (auth.kind === "anonymous") {
35
+ return new Response(JSON.stringify({ error: "forbidden: cannot read workspace state for another agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
36
+ }
37
+ if (auth.kind === "agent" && !auth.isAdmin && auth.agentId !== agentId) {
29
38
  return new Response(JSON.stringify({ error: "forbidden: cannot read workspace state for another agent" }), { status: 403, headers: { "Content-Type": "application/json" } });
30
39
  }
31
40
  // Query WorkspaceState table for this agent, sorted by timestamp desc
@@ -0,0 +1,248 @@
1
+ /**
2
+ * mcp-handler.ts — the Model-2 custom MCP protocol handler.
3
+ *
4
+ * A minimal in-process MCP (JSON-RPC 2.0) handler serving the 9 curated flair
5
+ * tools over Streamable HTTP. It is wrapped by `@harperfast/oauth`'s
6
+ * `withMCPAuth` (see mcp-oauth.ts), which fails closed on any missing/invalid
7
+ * Bearer token BEFORE this handler runs and, on success, sets
8
+ * `request.mcp = { sub, client_id, aud, scope }` (verified RS256 JWT claims).
9
+ *
10
+ * ── This handler's job ──────────────────────────────────────────────────────
11
+ * 1. Parse the JSON-RPC request (initialize / tools/list / tools/call / ping).
12
+ * 2. For tools/call: resolve `request.mcp.sub` → a flair `Agent` id via the
13
+ * `Credential(kind:"idp", idpSubject=sub)` lookup, JIT-provisioning a
14
+ * Principal+Credential the first time IF the trust anchor allows it.
15
+ * 3. Establish the flair scoping context and invoke the tool, which delegates
16
+ * to the existing resource handler (per-agent scoping enforced there).
17
+ *
18
+ * /mcp is its OWN dispatch chain (urlPath subroute) — flair's default
19
+ * auth-middleware does NOT run here, so this handler is solely responsible for
20
+ * turning the verified token into a scoped flair identity.
21
+ *
22
+ * ── Return shape ────────────────────────────────────────────────────────────
23
+ * Harper HTTP listeners return `{ status, body, headers? }`. MCP messages are
24
+ * JSON-RPC 2.0, so we serialize the JSON-RPC response object as the body.
25
+ */
26
+ import { databases } from "@harperfast/harper";
27
+ import { randomBytes } from "node:crypto";
28
+ import { TOOLS, listToolDefs } from "./mcp-tools.js";
29
+ // The MCP protocol revision we implement (initialize handshake).
30
+ const PROTOCOL_VERSION = "2025-06-18";
31
+ const JSON_HEADERS = { "content-type": "application/json" };
32
+ // ─── JSON-RPC helpers ────────────────────────────────────────────────────────
33
+ function rpcResult(id, result) {
34
+ return { status: 200, headers: JSON_HEADERS, body: JSON.stringify({ jsonrpc: "2.0", id, result }) };
35
+ }
36
+ function rpcError(id, code, message, httpStatus = 200) {
37
+ return {
38
+ status: httpStatus,
39
+ headers: JSON_HEADERS,
40
+ body: JSON.stringify({ jsonrpc: "2.0", id: id ?? null, error: { code, message } }),
41
+ };
42
+ }
43
+ // ─── sub → Agent resolution ─────────────────────────────────────────────────
44
+ /**
45
+ * Should an unknown IdP subject be JIT-provisioned into a new Principal+
46
+ * Credential? Gated by an explicit, auditable trust anchor — an OPEN JIT-provision
47
+ * means anyone who can obtain a token (which requires passing the AS's own login
48
+ * + DCR gate) auto-materializes a flair agent. That is the Sherlock req-4 boundary
49
+ * on the resolution side: provisioning is a deliberate decision, not a default.
50
+ *
51
+ * `FLAIR_MCP_JIT_PROVISION` — truthy ("1"/"true"/"yes"/"on") enables it. Default
52
+ * OFF: an unknown subject is denied (the operator must pre-provision the
53
+ * Agent+Credential, or explicitly opt into JIT). This composes with the AS-side
54
+ * DCR gate (initialAccessToken) — both must be deliberately opened.
55
+ */
56
+ function jitProvisionEnabled() {
57
+ const raw = (process.env.FLAIR_MCP_JIT_PROVISION ?? "").trim().toLowerCase();
58
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
59
+ }
60
+ /**
61
+ * Resolve the OAuth token `sub` to a flair `Agent` (Principal) id.
62
+ *
63
+ * Lookup: `Credential` where `kind === "idp"` AND `idpSubject === sub`. The
64
+ * Credential's `principalId` is the Agent id. This is the SAME credential surface
65
+ * XAA's ID-JAG path uses (resources/XAA.ts resolveOrCreatePrincipal) — one
66
+ * identity model, keyed on the IdP subject.
67
+ *
68
+ * Returns:
69
+ * - `{ agentId, isAdmin }` when a Credential maps the sub to an Agent.
70
+ * - null when no Credential maps the sub AND JIT-provisioning is disabled or
71
+ * failed → the handler denies the tool call (sub is unresolvable).
72
+ */
73
+ export async function resolveAgentFromSub(sub) {
74
+ if (!sub)
75
+ return null;
76
+ // 1. Existing IdP credential → its principalId is the Agent id.
77
+ try {
78
+ for await (const cred of databases.flair.Credential.search({
79
+ conditions: [
80
+ { attribute: "kind", comparator: "equals", value: "idp" },
81
+ { attribute: "idpSubject", comparator: "equals", value: sub },
82
+ ],
83
+ })) {
84
+ if (cred?.principalId && cred.status !== "revoked") {
85
+ // Touch lastUsedAt (best-effort; a failure here must not deny a valid call).
86
+ try {
87
+ await databases.flair.Credential.put({ ...cred, lastUsedAt: new Date().toISOString() });
88
+ }
89
+ catch { /* non-fatal */ }
90
+ return { agentId: String(cred.principalId), isAdmin: await isAgentAdmin(cred.principalId) };
91
+ }
92
+ }
93
+ }
94
+ catch { /* Credential table empty / search error → fall through to JIT/deny */ }
95
+ // 2. No mapping. JIT-provision only behind the explicit trust anchor.
96
+ if (!jitProvisionEnabled())
97
+ return null;
98
+ try {
99
+ const principalId = await jitProvisionPrincipal(sub);
100
+ // A JIT-provisioned principal is a fresh, non-admin agent by construction.
101
+ return { agentId: principalId, isAdmin: false };
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ /**
108
+ * JIT-provision a Principal (Agent record) + an IdP Credential from a verified
109
+ * token subject. Mirrors XAA.resolveOrCreatePrincipal's provisioning shape (the
110
+ * `Credential.kind:"idp"` + `idpSubject` surface) but keyed on the MCP token sub.
111
+ * The created agent is non-admin, `kind:"agent"`, unverified trust tier.
112
+ */
113
+ async function jitProvisionPrincipal(sub) {
114
+ const now = new Date().toISOString();
115
+ const principalId = `agt_mcp_${sub.replace(/[^a-zA-Z0-9]/g, "_").slice(0, 24)}_${randomBytes(4).toString("hex")}`;
116
+ await databases.flair.Agent.put({
117
+ id: principalId,
118
+ name: principalId,
119
+ displayName: principalId,
120
+ kind: "agent",
121
+ type: "agent",
122
+ status: "active",
123
+ // Placeholder public key — an MCP-OAuth agent authenticates via bearer token,
124
+ // not an Ed25519 signing key. Marks provenance without forging a real key.
125
+ publicKey: `mcp-oauth:${sub}`,
126
+ defaultTrustTier: "unverified",
127
+ admin: false,
128
+ createdAt: now,
129
+ updatedAt: now,
130
+ });
131
+ await databases.flair.Credential.put({
132
+ id: `cred_mcp_${randomBytes(8).toString("hex")}`,
133
+ principalId,
134
+ kind: "idp",
135
+ label: "MCP OAuth (native /mcp)",
136
+ status: "active",
137
+ idpProvider: "mcp-oauth",
138
+ idpSubject: sub,
139
+ createdAt: now,
140
+ lastUsedAt: now,
141
+ });
142
+ return principalId;
143
+ }
144
+ /**
145
+ * Is this Principal a flair admin? Reads the Agent record's `admin`/`role`
146
+ * fields. A MCP-OAuth agent is NON-admin unless an operator has explicitly
147
+ * marked its Agent record admin — the MCP surface never elevates on its own.
148
+ */
149
+ async function isAgentAdmin(principalId) {
150
+ try {
151
+ const agent = await databases.flair.Agent.get(principalId);
152
+ return agent?.admin === true || agent?.role === "admin";
153
+ }
154
+ catch {
155
+ return false;
156
+ }
157
+ }
158
+ // ─── MCP protocol dispatch ───────────────────────────────────────────────────
159
+ /**
160
+ * The custom /mcp handler. `withMCPAuth` guarantees `request.mcp` is present here
161
+ * (it fails closed before us on a missing/invalid token), so we read the verified
162
+ * `sub` directly. Handles a single JSON-RPC request per POST (the minimal
163
+ * Streamable-HTTP shape the curated surface needs; batching is not used by the
164
+ * MCP clients we target).
165
+ */
166
+ export async function mcpHandler(request) {
167
+ // MCP is a POST-only JSON-RPC surface. A GET (e.g. an SSE stream open) is not
168
+ // part of the curated request/response tool flow — reject cleanly.
169
+ const method = String(request?.method ?? "POST").toUpperCase();
170
+ if (method !== "POST") {
171
+ return rpcError(null, -32600, "method not allowed: /mcp accepts JSON-RPC POST only", 405);
172
+ }
173
+ // Parse the JSON-RPC body. Harper's Request wraps a Node stream — read text.
174
+ let msg;
175
+ try {
176
+ const text = typeof request.text === "function" ? await request.text() : request.body;
177
+ msg = typeof text === "string" ? JSON.parse(text) : text;
178
+ }
179
+ catch {
180
+ return rpcError(null, -32700, "parse error: invalid JSON");
181
+ }
182
+ if (!msg || typeof msg !== "object" || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") {
183
+ return rpcError(msg?.id ?? null, -32600, "invalid request: expected JSON-RPC 2.0");
184
+ }
185
+ const { id, method: rpcMethod, params } = msg;
186
+ switch (rpcMethod) {
187
+ case "initialize":
188
+ return rpcResult(id, {
189
+ protocolVersion: PROTOCOL_VERSION,
190
+ capabilities: { tools: {} },
191
+ serverInfo: { name: "flair", version: "0.1.0" },
192
+ });
193
+ // Notifications (no id) — acknowledge with 202-ish empty 200; MCP clients send
194
+ // `notifications/initialized` after initialize.
195
+ case "notifications/initialized":
196
+ return { status: 200, headers: JSON_HEADERS, body: "" };
197
+ case "ping":
198
+ return rpcResult(id, {});
199
+ case "tools/list":
200
+ return rpcResult(id, { tools: listToolDefs() });
201
+ case "tools/call":
202
+ return handleToolCall(request, id, params);
203
+ default:
204
+ return rpcError(id, -32601, `method not found: ${rpcMethod}`);
205
+ }
206
+ }
207
+ /**
208
+ * tools/call: resolve the token sub → flair Agent, then dispatch to the curated
209
+ * tool. An unresolvable sub is DENIED (not silently run as anonymous or admin).
210
+ */
211
+ async function handleToolCall(request, id, params) {
212
+ const toolName = params?.name;
213
+ const args = params?.arguments ?? {};
214
+ const entry = toolName ? TOOLS[toolName] : undefined;
215
+ if (!entry) {
216
+ return rpcError(id, -32602, `unknown tool: ${toolName ?? "(none)"}`);
217
+ }
218
+ // withMCPAuth guarantees request.mcp on success. Defense-in-depth: if it's
219
+ // somehow absent, deny (never run a tool without a verified sub).
220
+ const sub = request?.mcp?.sub;
221
+ if (!sub) {
222
+ return rpcError(id, -32001, "unauthorized: no verified token subject");
223
+ }
224
+ const agent = await resolveAgentFromSub(String(sub));
225
+ if (!agent) {
226
+ // Sub verified by the AS but not mapped to a flair Agent (and JIT disabled /
227
+ // failed). Deny — do NOT fall back to anonymous or admin.
228
+ return rpcError(id, -32001, "forbidden: token subject is not a provisioned flair agent");
229
+ }
230
+ try {
231
+ const result = await entry.impl(agent, args);
232
+ // MCP tools/call result: content blocks. Surface the handler's JSON payload
233
+ // as a text block (structuredContent carries the raw object for programmatic
234
+ // clients). A handler-level error object (from unwrap of a Response) is
235
+ // reported as an MCP tool error (isError) rather than a JSON-RPC error, so
236
+ // the client sees the structured message.
237
+ const text = typeof result === "string" ? result : JSON.stringify(result);
238
+ const isError = !!(result && typeof result === "object" && "error" in result && "status" in result);
239
+ return rpcResult(id, {
240
+ content: [{ type: "text", text }],
241
+ structuredContent: typeof result === "object" ? result : { value: result },
242
+ isError,
243
+ });
244
+ }
245
+ catch (err) {
246
+ return rpcError(id, -32000, `tool execution failed: ${err?.message ?? String(err)}`);
247
+ }
248
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * mcp-oauth-flag.ts — the feature flag + AS config for the native-MCP OAuth
3
+ * surface (FLAIR-NATIVE-MCP-OAUTH, Model 2).
4
+ *
5
+ * Model 2 = a CUSTOM in-process `/mcp` JSON-RPC handler wrapped with
6
+ * `@harperfast/oauth`'s `withMCPAuth` (a fail-closed Bearer-token guard). It is
7
+ * DISTINCT from the native-application-MCP surface (design A / the
8
+ * `native-mcp-surface` branch's `static mcpTools`) — Model 2 does not use
9
+ * Harper's native MCP transport at all, so it is not blocked by the Harper
10
+ * native-MCP timing/worker gating gaps. The curated 9 tools are curated
11
+ * BY CONSTRUCTION (the handler only implements those 9).
12
+ *
13
+ * ── Default-OFF ─────────────────────────────────────────────────────────────
14
+ * The whole surface is gated behind `FLAIR_MCP_OAUTH`, default-OFF. When off:
15
+ * - NO `/mcp` route is registered (mcpOAuthResource() early-returns).
16
+ * - The `@harperfast/oauth` authorization-server config is NOT injected.
17
+ * - flair's default auth chain is byte-identical to today.
18
+ * There is zero prod-behavior change until an operator explicitly opts in AND
19
+ * Sherlock signs off on live enablement.
20
+ *
21
+ * NOTE — separate flag from the native-MCP surface: `FLAIR_MCP_ENABLED` gates the
22
+ * design-A native surface (other branch); `FLAIR_MCP_OAUTH` gates THIS Model-2
23
+ * OAuth-guarded custom handler. They are independent flags for independent
24
+ * mechanisms; neither implies the other.
25
+ */
26
+ /**
27
+ * Is the Model-2 OAuth-guarded /mcp surface enabled? Default-OFF.
28
+ *
29
+ * Read from `FLAIR_MCP_OAUTH` — truthy values: "1", "true", "yes", "on"
30
+ * (case-insensitive). Anything else (incl. unset / empty) → OFF.
31
+ */
32
+ export function mcpOAuthEnabled() {
33
+ const raw = (process.env.FLAIR_MCP_OAUTH ?? "").trim().toLowerCase();
34
+ return raw === "1" || raw === "true" || raw === "yes" || raw === "on";
35
+ }
36
+ /**
37
+ * The public origin the authorization server pins `iss` (and, via it, `aud`) to.
38
+ * REQUIRED when the surface is enabled — the @harperfast/oauth plugin refuses to
39
+ * start without `mcp.issuer`, and pinning it (rather than letting it float with
40
+ * the client-controlled Host header) is the audience-confusion defense called
41
+ * out in the plugin's production checklist.
42
+ *
43
+ * Configurable via `FLAIR_MCP_ISSUER` (fall back to `FLAIR_PUBLIC_URL`, which the
44
+ * XAA token path already uses as the canonical public base). No hardcoded
45
+ * default — an operator turning the flag on MUST set the public origin.
46
+ */
47
+ export function mcpIssuer() {
48
+ const raw = (process.env.FLAIR_MCP_ISSUER ?? process.env.FLAIR_PUBLIC_URL ?? "").trim();
49
+ return raw || undefined;
50
+ }
51
+ /**
52
+ * The RFC-8707 resource identifier tokens are audience-bound to. This is the
53
+ * public URL of the `/mcp` endpoint itself: `<issuer>/mcp`. `withMCPAuth`
54
+ * verifies the `aud` claim equals this, so the token minted for flair's `/mcp`
55
+ * cannot be replayed against a different resource server.
56
+ */
57
+ export function mcpResource() {
58
+ const iss = mcpIssuer();
59
+ if (!iss)
60
+ return undefined;
61
+ return `${iss.replace(/\/+$/, "")}/mcp`;
62
+ }
63
+ /**
64
+ * `getConfig` payload injected into `withMCPAuth` so it verifies against the
65
+ * exact issuer/resource the authorization-server component mints tokens with —
66
+ * required because flair's `/mcp` handler and the @harperfast/oauth plugin may
67
+ * resolve different `node_modules` copies (docs/mcp-oauth.md §"Using withMCPAuth
68
+ * from a different component"), in which case the wrapper's live-config lookup
69
+ * reads `undefined` and fails closed. Pinning it here makes the iss/aud checks
70
+ * match the minted tokens.
71
+ *
72
+ * Returns undefined when the surface is off or the issuer is unset (the wrapper
73
+ * then denies — never serves a guarded route unconfigured, which is the safe
74
+ * failure mode).
75
+ */
76
+ export function mcpAuthConfig() {
77
+ if (!mcpOAuthEnabled())
78
+ return undefined;
79
+ const issuer = mcpIssuer();
80
+ const resource = mcpResource();
81
+ if (!issuer || !resource)
82
+ return undefined;
83
+ return { enabled: true, issuer, resource };
84
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * mcp-oauth.ts — registers the Model-2 OAuth-guarded /mcp surface.
3
+ *
4
+ * Wraps the custom `mcpHandler` (mcp-handler.ts) with `@harperfast/oauth`'s
5
+ * `withMCPAuth` (a fail-closed Bearer-token guard) and mounts it on the `/mcp`
6
+ * urlPath subroute — its OWN dispatch chain, so flair's default auth-middleware
7
+ * never runs for /mcp and can't clobber the Bearer challenge.
8
+ *
9
+ * ── Default-OFF (byte-identical when off) ───────────────────────────────────
10
+ * The route is registered ONLY when `FLAIR_MCP_OAUTH` is truthy. When off, this
11
+ * module does NOTHING at load — no `server.http` call, no `@harperfast/oauth`
12
+ * import, no config injection. flair's default auth chain and prod behavior are
13
+ * unchanged. This is the no-op contract the flag guarantees.
14
+ *
15
+ * The `@harperfast/oauth` authorization-server config itself (providers, mcp.*,
16
+ * DCR gating) lives in `config.yaml` under the `@harperfast/oauth` key, but is
17
+ * only meaningful when an operator has set the issuer + enabled the flag (see
18
+ * docs). The plugin serves DCR / authorize / token / JWKS / discovery.
19
+ */
20
+ import * as harper from "@harperfast/harper";
21
+ import { mcpOAuthEnabled, mcpAuthConfig } from "./mcp-oauth-flag.js";
22
+ import { mcpHandler } from "./mcp-handler.js";
23
+ async function defaultLoadWithMCPAuth() {
24
+ // Dynamic import so the dep is only required when the surface is enabled.
25
+ const mod = (await import("@harperfast/oauth"));
26
+ return mod.withMCPAuth;
27
+ }
28
+ export async function registerMcpOAuthRoute(deps = {}) {
29
+ if (!mcpOAuthEnabled())
30
+ return false; // OFF → no route, no import, no side effects.
31
+ const config = mcpAuthConfig();
32
+ if (!config) {
33
+ // Flag on but issuer unset → we cannot safely pin iss/aud. Do NOT mount an
34
+ // unconfigured guard (withMCPAuth would fail closed anyway, but not mounting
35
+ // is the clearer signal). Log and bail — the operator must set FLAIR_MCP_ISSUER.
36
+ console.error("[mcp-oauth] FLAIR_MCP_OAUTH is on but no issuer configured " +
37
+ "(set FLAIR_MCP_ISSUER or FLAIR_PUBLIC_URL) — /mcp NOT mounted.");
38
+ return false;
39
+ }
40
+ let withMCPAuth;
41
+ try {
42
+ withMCPAuth = await (deps.loadWithMCPAuth ?? defaultLoadWithMCPAuth)();
43
+ }
44
+ catch (err) {
45
+ console.error("[mcp-oauth] @harperfast/oauth not available — /mcp NOT mounted: " + (err?.message ?? err));
46
+ return false;
47
+ }
48
+ if (typeof withMCPAuth !== "function") {
49
+ console.error("[mcp-oauth] @harperfast/oauth has no withMCPAuth export — /mcp NOT mounted.");
50
+ return false;
51
+ }
52
+ // Read `server` lazily off the namespace (it's a runtime global on the Harper
53
+ // module, not a static named export) so this module links cleanly even where a
54
+ // stub build of @harperfast/harper lacks the export.
55
+ const srv = deps.server ?? (harper.server);
56
+ // Primary registration: urlPath subroute → own chain (flair's auth-middleware
57
+ // does not run here). `getConfig` pins iss/resource to the AS's values so the
58
+ // wrapper's iss/aud checks match the minted tokens even if this component
59
+ // resolves a different node_modules copy of the plugin (docs/mcp-oauth.md
60
+ // §"Using withMCPAuth from a different component").
61
+ srv.http(withMCPAuth(mcpHandler, {
62
+ getConfig: () => mcpAuthConfig(),
63
+ }), { urlPath: "/mcp" });
64
+ console.error(`[mcp-oauth] /mcp mounted (OAuth-guarded); issuer=${config.issuer}`);
65
+ return true;
66
+ }
67
+ // Fire-and-forget at module load. Any failure is contained inside
68
+ // registerMcpOAuthRoute (it logs and returns) so it can never crash flair boot.
69
+ // When the flag is off it returns immediately without importing the plugin or
70
+ // touching `server` — the byte-identical no-op contract.
71
+ //
72
+ // Skipped ONLY when a test explicitly opts out via FLAIR_MCP_NO_AUTOSTART, so
73
+ // importing this module in a unit test doesn't trigger the real plugin/handler
74
+ // import chain under a partial harper mock (registration is exercised directly
75
+ // via the exported fn). Production never sets this, so boot behavior is
76
+ // unchanged — and when the flag is off, registerMcpOAuthRoute() is a no-op
77
+ // regardless. (bun test runs under Node's runtime here via the harper toolchain;
78
+ // we don't gate on the runtime to avoid disabling the feature in a bun-hosted
79
+ // deployment.)
80
+ if (process.env.FLAIR_MCP_NO_AUTOSTART == null) {
81
+ void registerMcpOAuthRoute().catch((err) => {
82
+ console.error("[mcp-oauth] route registration failed (surface not mounted): " + (err?.message ?? err));
83
+ });
84
+ }
@@ -0,0 +1,347 @@
1
+ /**
2
+ * mcp-tools.ts — the 9 curated flair tools for the Model-2 custom /mcp handler.
3
+ *
4
+ * Curated BY CONSTRUCTION: this module implements exactly the 9 tools that the
5
+ * `@tpsdev-ai/flair-mcp` stdio proxy exposes, each a thin wrapper over the
6
+ * existing flair Resource handler. No business logic is re-implemented — the
7
+ * wrapped handlers (Memory / SemanticSearch / BootstrapMemories / Soul /
8
+ * WorkspaceState / OrgEvent) enforce per-agent scoping/ownership via
9
+ * `resolveAgentAuth(getContext())`, so the MCP surface inherits the SAME security
10
+ * model as the signed-REST path. There is no raw CRUD surface — the only way to
11
+ * reach the datastore through /mcp is via one of these 9 semantic tools.
12
+ *
13
+ * memory_search · memory_store · memory_get · memory_delete · bootstrap ·
14
+ * soul_set · soul_get · flair_workspace_set · flair_orgevent
15
+ *
16
+ * ── The scoping seam ────────────────────────────────────────────────────────
17
+ * The /mcp handler resolves the OAuth token's `sub` → a flair `Agent` id, then
18
+ * calls a tool with a `ResolvedAgent { agentId, isAdmin }`. Each tool builds a
19
+ * flair-shaped Resource context (`delegationContext`) carrying `request.tpsAgent`
20
+ * + `request.tpsAgentIsAdmin`, so the wrapped handler scopes to the verified
21
+ * agent exactly as an Ed25519-signed REST call would. Identity ALWAYS comes from
22
+ * the resolved agent, never from the tool arguments — an agent can only act as
23
+ * itself (no forging of agentId / authorId in the body).
24
+ */
25
+ const H = {};
26
+ const LOADERS = {
27
+ SemanticSearch: async () => (await import("./SemanticSearch.js")).SemanticSearch,
28
+ Memory: async () => (await import("./Memory.js")).Memory,
29
+ BootstrapMemories: async () => (await import("./MemoryBootstrap.js")).BootstrapMemories,
30
+ Soul: async () => (await import("./Soul.js")).Soul,
31
+ WorkspaceState: async () => (await import("./WorkspaceState.js")).WorkspaceState,
32
+ OrgEvent: async () => (await import("./OrgEvent.js")).OrgEvent,
33
+ };
34
+ /** Resolve a handler class — from the test override if set, else lazy-load + cache. */
35
+ async function handler(key) {
36
+ if (H[key])
37
+ return H[key];
38
+ const cls = await LOADERS[key]();
39
+ H[key] = cls;
40
+ return cls;
41
+ }
42
+ /** TEST-ONLY: override the delegated handler classes. Returns a restore fn. */
43
+ export function __setHandlers(overrides) {
44
+ const prev = { ...H };
45
+ Object.assign(H, overrides);
46
+ return () => {
47
+ for (const k of Object.keys(H))
48
+ delete H[k];
49
+ Object.assign(H, prev);
50
+ };
51
+ }
52
+ /**
53
+ * Build a flair-shaped Resource context for a delegated handler call. The
54
+ * handlers read identity via `resolveAgentAuth(getContext())`, which checks
55
+ * `context.request.tpsAgent` / `tpsAgentIsAdmin`. We construct exactly that shape
56
+ * so the wrapped handler scopes to the verified agent — identical to the
57
+ * signed-REST path. `headers.get("x-tps-agent")` is provided for handler paths
58
+ * that read the header directly (e.g. MemoryBootstrap fallback).
59
+ *
60
+ * Critically: `tpsAnonymous` is NOT set and no Authorization header is present,
61
+ * so `resolveAgentAuth` takes the `tpsAgent` annotation branch — a verified
62
+ * agent, never anonymous, never a header re-verify.
63
+ */
64
+ function delegationContext(agent) {
65
+ return {
66
+ request: {
67
+ tpsAgent: agent.agentId,
68
+ tpsAgentIsAdmin: agent.isAdmin,
69
+ headers: {
70
+ get: (k) => (k.toLowerCase() === "x-tps-agent" ? agent.agentId : undefined),
71
+ },
72
+ },
73
+ user: undefined,
74
+ };
75
+ }
76
+ /**
77
+ * Unwrap a handler return value into a plain object/string for the MCP result.
78
+ * Handlers may return a `Response` (the 401/403/400 guards) — surface its JSON
79
+ * body (and status) so the client sees the structured error rather than an
80
+ * opaque object. A thrown handler error propagates to the caller (the handler
81
+ * maps it to a JSON-RPC error).
82
+ */
83
+ async function unwrap(value) {
84
+ if (value && typeof value === "object" && typeof value.json === "function" && "status" in value) {
85
+ try {
86
+ const body = await value.json();
87
+ return { error: body?.error ?? "request failed", status: value.status, ...body };
88
+ }
89
+ catch {
90
+ return { error: "request failed", status: value.status };
91
+ }
92
+ }
93
+ return value;
94
+ }
95
+ // ── Tool implementations (thin wrappers over existing handlers) ──────────────
96
+ //
97
+ // Each takes the resolved agent + the parsed tool arguments and returns a plain
98
+ // JSON-serializable value. Identity is taken from `agent`, never from `args`.
99
+ async function memorySearch(agent, args) {
100
+ const Cls = await handler("SemanticSearch");
101
+ const h = new Cls(undefined, delegationContext(agent));
102
+ return unwrap(await h.post({ q: args?.query, limit: args?.limit ?? 5 }));
103
+ }
104
+ async function memoryStore(agent, args) {
105
+ const Cls = await handler("Memory");
106
+ const h = new Cls(undefined, delegationContext(agent));
107
+ h.isCollection = true;
108
+ // agentId is the RESOLVED agent — Memory.post also re-checks ownership via
109
+ // resolveAgentAuth, so a mismatched body agentId would 403 anyway; we set it
110
+ // to the verified id so the write is correctly owned.
111
+ return unwrap(await h.post({
112
+ agentId: agent.agentId,
113
+ content: args?.content,
114
+ type: args?.type ?? "session",
115
+ durability: args?.durability ?? "standard",
116
+ tags: args?.tags,
117
+ }));
118
+ }
119
+ async function memoryGet(agent, args) {
120
+ const Cls = await handler("Memory");
121
+ const h = new Cls(undefined, delegationContext(agent));
122
+ return unwrap(await h.get(args?.id));
123
+ }
124
+ async function memoryDelete(agent, args) {
125
+ const Cls = await handler("Memory");
126
+ const h = new Cls(undefined, delegationContext(agent));
127
+ return unwrap(await h.delete(args?.id));
128
+ }
129
+ async function bootstrap(agent, args) {
130
+ const Cls = await handler("BootstrapMemories");
131
+ const h = new Cls(undefined, delegationContext(agent));
132
+ return unwrap(await h.post({
133
+ agentId: agent.agentId,
134
+ maxTokens: args?.maxTokens ?? 4000,
135
+ currentTask: args?.currentTask,
136
+ channel: args?.channel,
137
+ surface: args?.surface,
138
+ subjects: args?.subjects,
139
+ }));
140
+ }
141
+ async function soulSet(agent, args) {
142
+ const Cls = await handler("Soul");
143
+ const h = new Cls(undefined, delegationContext(agent));
144
+ // Soul records are keyed `id = agentId:key` (see flair-client SoulApi.set and
145
+ // schemas/memory.graphql). Use PUT with the explicit id so soul_get's
146
+ // `${agentId}:${key}` lookup finds it — a plain post() would mint a random id
147
+ // and orphan the entry from get(). Soul.put enforces write ownership via
148
+ // resolveAgentAuth (non-admin can only write agentId === self).
149
+ const id = `${agent.agentId}:${args?.key}`;
150
+ return unwrap(await h.put({
151
+ id,
152
+ agentId: agent.agentId,
153
+ key: args?.key,
154
+ value: args?.value,
155
+ }));
156
+ }
157
+ async function soulGet(agent, args) {
158
+ const Cls = await handler("Soul");
159
+ const h = new Cls(undefined, delegationContext(agent));
160
+ return unwrap(await h.get(`${agent.agentId}:${args?.key}`));
161
+ }
162
+ async function workspaceSet(agent, args) {
163
+ const Cls = await handler("WorkspaceState");
164
+ const h = new Cls(undefined, delegationContext(agent));
165
+ h.isCollection = true;
166
+ // No agentId in the body — WorkspaceState.post attributes the record to the
167
+ // authenticated identity (from the context), never the body. Same no-forge
168
+ // contract as the flair-mcp stdio tool.
169
+ const body = {
170
+ id: `${agent.agentId}:${args?.ref}`,
171
+ ref: args?.ref,
172
+ provider: args?.provider ?? "mcp",
173
+ timestamp: new Date().toISOString(),
174
+ };
175
+ if (args?.label)
176
+ body.label = args.label;
177
+ if (args?.task)
178
+ body.taskId = args.task;
179
+ if (args?.phase)
180
+ body.phase = args.phase;
181
+ if (args?.summary)
182
+ body.summary = args.summary;
183
+ return unwrap(await h.post(body));
184
+ }
185
+ async function orgEvent(agent, args) {
186
+ const Cls = await handler("OrgEvent");
187
+ const h = new Cls(undefined, delegationContext(agent));
188
+ h.isCollection = true;
189
+ // No authorId in the body — OrgEvent.post attributes to the authenticated
190
+ // identity, never the body (no forging as another agent).
191
+ const body = { kind: args?.kind, summary: args?.summary };
192
+ if (args?.detail)
193
+ body.detail = args.detail;
194
+ if (args?.scope)
195
+ body.scope = args.scope;
196
+ if (Array.isArray(args?.targets) && args.targets.length > 0)
197
+ body.targetIds = args.targets;
198
+ return unwrap(await h.post(body));
199
+ }
200
+ export const TOOLS = {
201
+ memory_search: {
202
+ def: {
203
+ name: "memory_search",
204
+ description: "Search memories by meaning. Understands temporal queries like 'what happened today'. Scoped to your agent's own + granted memories.",
205
+ annotations: { readOnlyHint: true },
206
+ inputSchema: {
207
+ type: "object",
208
+ properties: {
209
+ query: { type: "string", description: "Search query — natural language, semantic matching" },
210
+ limit: { type: "number", description: "Max results (default 5)" },
211
+ },
212
+ required: ["query"],
213
+ },
214
+ },
215
+ impl: memorySearch,
216
+ },
217
+ memory_store: {
218
+ def: {
219
+ name: "memory_store",
220
+ description: "Save information to persistent memory. Use for lessons, decisions, preferences, facts. Attributed to your authenticated agent.",
221
+ inputSchema: {
222
+ type: "object",
223
+ properties: {
224
+ content: { type: "string", description: "What to remember" },
225
+ type: { type: "string", enum: ["session", "lesson", "decision", "preference", "fact", "goal"], description: "Memory type (default session)" },
226
+ durability: { type: "string", enum: ["permanent", "persistent", "standard", "ephemeral"], description: "permanent > persistent > standard > ephemeral (default standard)" },
227
+ tags: { type: "array", items: { type: "string" }, description: "Tag strings" },
228
+ },
229
+ required: ["content"],
230
+ },
231
+ },
232
+ impl: memoryStore,
233
+ },
234
+ memory_get: {
235
+ def: {
236
+ name: "memory_get",
237
+ description: "Retrieve a specific memory by ID.",
238
+ annotations: { readOnlyHint: true },
239
+ inputSchema: {
240
+ type: "object",
241
+ properties: { id: { type: "string", description: "Memory ID" } },
242
+ required: ["id"],
243
+ },
244
+ },
245
+ impl: memoryGet,
246
+ },
247
+ memory_delete: {
248
+ def: {
249
+ name: "memory_delete",
250
+ description: "Delete a memory by ID. You can only delete your own memories.",
251
+ annotations: { destructiveHint: true },
252
+ inputSchema: {
253
+ type: "object",
254
+ properties: { id: { type: "string", description: "Memory ID to delete" } },
255
+ required: ["id"],
256
+ },
257
+ },
258
+ impl: memoryDelete,
259
+ },
260
+ bootstrap: {
261
+ def: {
262
+ name: "bootstrap",
263
+ description: "Get session context: soul + memories + predicted context. Run at session start. Pass subjects for predictive loading.",
264
+ annotations: { readOnlyHint: true },
265
+ inputSchema: {
266
+ type: "object",
267
+ properties: {
268
+ maxTokens: { type: "number", description: "Max tokens in output (default 4000)" },
269
+ currentTask: { type: "string", description: "Current task — enables semantic search for relevant memories" },
270
+ channel: { type: "string", description: "Channel name (discord, tps-mail, claude-code)" },
271
+ surface: { type: "string", description: "Surface name (tps-build, tps-review, cli-session)" },
272
+ subjects: { type: "array", items: { type: "string" }, description: "Entity names to preload context for" },
273
+ },
274
+ },
275
+ },
276
+ impl: bootstrap,
277
+ },
278
+ soul_set: {
279
+ def: {
280
+ name: "soul_set",
281
+ description: "Set a personality or project context entry. Included in every bootstrap.",
282
+ inputSchema: {
283
+ type: "object",
284
+ properties: {
285
+ key: { type: "string", description: "Entry key (e.g. 'role', 'standards', 'project')" },
286
+ value: { type: "string", description: "Entry value" },
287
+ },
288
+ required: ["key", "value"],
289
+ },
290
+ },
291
+ impl: soulSet,
292
+ },
293
+ soul_get: {
294
+ def: {
295
+ name: "soul_get",
296
+ description: "Get a personality or project context entry.",
297
+ annotations: { readOnlyHint: true },
298
+ inputSchema: {
299
+ type: "object",
300
+ properties: { key: { type: "string", description: "Entry key" } },
301
+ required: ["key"],
302
+ },
303
+ },
304
+ impl: soulGet,
305
+ },
306
+ flair_workspace_set: {
307
+ def: {
308
+ name: "flair_workspace_set",
309
+ description: "Set your agent's current workspace state in the Office Space coordination layer. Attributed to you — you can only write your own state.",
310
+ inputSchema: {
311
+ type: "object",
312
+ properties: {
313
+ ref: { type: "string", description: "Workspace ref — branch, worktree, or task ref" },
314
+ label: { type: "string", description: "Human-readable label" },
315
+ provider: { type: "string", description: "Provider/runtime (default mcp)" },
316
+ task: { type: "string", description: "Task/issue id" },
317
+ phase: { type: "string", description: "Current phase (design, implement, review)" },
318
+ summary: { type: "string", description: "Short summary of current state" },
319
+ },
320
+ required: ["ref"],
321
+ },
322
+ },
323
+ impl: workspaceSet,
324
+ },
325
+ flair_orgevent: {
326
+ def: {
327
+ name: "flair_orgevent",
328
+ description: "Publish an org-wide coordination event (claim/release/status) to the Office Space. Attributed to you — you cannot publish as another agent.",
329
+ inputSchema: {
330
+ type: "object",
331
+ properties: {
332
+ kind: { type: "string", description: "Event kind (coord.claim, coord.release, status)" },
333
+ summary: { type: "string", description: "Short summary of the event" },
334
+ detail: { type: "string", description: "Longer detail payload" },
335
+ scope: { type: "string", description: "Scope (an agent id, repo, or 'org')" },
336
+ targets: { type: "array", items: { type: "string" }, description: "Recipient agent ids" },
337
+ },
338
+ required: ["kind", "summary"],
339
+ },
340
+ },
341
+ impl: orgEvent,
342
+ },
343
+ };
344
+ /** The tool definitions for a tools/list response (exactly the 9 curated tools). */
345
+ export function listToolDefs() {
346
+ return Object.values(TOOLS).map((t) => t.def);
347
+ }
@@ -0,0 +1,42 @@
1
+ // ─── MemoryBootstrap — pure evaluation logic ────────────────────────────────
2
+ // Pure helpers extracted from MemoryBootstrap.ts (section 1c, PR #549) so they
3
+ // can be unit-tested directly. Importing MemoryBootstrap.ts pulls in the
4
+ // Harper runtime (`databases` / `Resource`, storage init) and can't run
5
+ // outside a live Harper; this module has no Harper dependency, so
6
+ // test/unit/bootstrap-team.test.ts exercises the real shipped code.
7
+ import { wrapUntrusted } from "./content-safety.js";
8
+ /**
9
+ * Is `record` a live teammate of `callerId` for roster purposes?
10
+ *
11
+ * Permissive by design: pre-1.0 Agent records may not have `kind`/`status` at
12
+ * all (Agent.ts registration only started defaulting both — `kind ||= "agent"`,
13
+ * `status ||= "active"` — from the 1.0 auth reshape onward). A missing field
14
+ * means "legacy agent, active", not "unknown, exclude" — so we only exclude on
15
+ * an explicit non-matching value, never on absence.
16
+ */
17
+ export function isTeammate(record, callerId) {
18
+ if (record.id === callerId)
19
+ return false;
20
+ if (record.kind && record.kind !== "agent")
21
+ return false;
22
+ if (record.status && record.status !== "active")
23
+ return false;
24
+ return true;
25
+ }
26
+ /**
27
+ * Format the "## Team" roster line for a list of teammate ids, or `null`
28
+ * when the roster is empty (caller should omit the section entirely).
29
+ *
30
+ * Teammate ids are registrant-chosen strings, not something Flair controls —
31
+ * they're untrusted the same way memory content is, so only the id list goes
32
+ * through wrapUntrusted; the surrounding instructional text is trusted and
33
+ * stays outside the wrap.
34
+ */
35
+ export function formatTeamLine(teammateIds) {
36
+ if (teammateIds.length === 0)
37
+ return null;
38
+ const plural = teammateIds.length === 1 ? "agent shares" : "agents share";
39
+ return (`${teammateIds.length} other ${plural} this Flair office (${wrapUntrusted(teammateIds.join(", "))}). ` +
40
+ `Before deep-diving an unfamiliar problem, search their memories for related work — ` +
41
+ `\`memory_search\` covers any agent you hold a search grant from.`);
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tpsdev-ai/flair",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Identity, memory, and soul for AI agents. Cryptographic identity (Ed25519), semantic memory with local embeddings, and persistent personality — all in a single process.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -56,6 +56,7 @@
56
56
  },
57
57
  "dependencies": {
58
58
  "@harperfast/harper": "5.1.14",
59
+ "@harperfast/oauth": "2.1.0",
59
60
  "@types/js-yaml": "4.0.9",
60
61
  "commander": "14.0.3",
61
62
  "harper-fabric-embeddings": "0.2.3",