@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 +29 -2
- package/dist/resources/AdminMemory.js +6 -1
- package/dist/resources/AgentSeed.js +8 -1
- package/dist/resources/IngestEvents.js +7 -1
- package/dist/resources/MemoryBootstrap.js +35 -0
- package/dist/resources/OAuth.js +30 -4
- package/dist/resources/OrgEventCatchup.js +14 -6
- package/dist/resources/WorkspaceLatest.js +15 -6
- package/dist/resources/mcp-handler.js +248 -0
- package/dist/resources/mcp-oauth-flag.js +84 -0
- package/dist/resources/mcp-oauth.js +84 -0
- package/dist/resources/mcp-tools.js +347 -0
- package/dist/resources/memory-bootstrap-lib.js +42 -0
- package/package.json +2 -1
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/dist/resources/OAuth.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
198
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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:
|
|
36
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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:
|
|
28
|
-
|
|
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.
|
|
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",
|