@tpsdev-ai/flair 0.16.1 → 0.18.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.
@@ -4,6 +4,7 @@ import { getEmbedding, getMode } from "./embeddings-provider.js";
4
4
  import { patchRecord, withDetachedTxn } from "./table-helpers.js";
5
5
  import { checkRateLimit, rateLimitResponse } from "./rate-limiter.js";
6
6
  import { wrapUntrusted } from "./content-safety.js";
7
+ import { isRerankEnabled, getRerankTopN, getRerankBudgetMs, getRerankMinCandidates, rerankCandidates, } from "./rerank-provider.js";
7
8
  // Temporal decay + relevance scoring (incl. the OPS-AYGD retrievalBoost cap +
8
9
  // relevance floor) lives in ./scoring.ts — a Harper-free module so it can be
9
10
  // unit-tested directly (see test/unit/temporal-scoring.test.ts).
@@ -170,6 +171,16 @@ export class SemanticSearch extends Resource {
170
171
  }
171
172
  const results = [];
172
173
  const hybrid = hybridEnabled();
174
+ // When the reranker is on, widen the legacy HNSW fetch so it has a deeper
175
+ // pool to re-score (retrieve topN → rerank → slice to limit). Decoupled
176
+ // from CANDIDATE_MULTIPLIER so composite re-ranking keeps its existing
177
+ // headroom. Scoped to the legacy (non-hybrid) vector path below — the
178
+ // hybrid path's candidate pool is already governed by CANDIDATE_MULTIPLIER
179
+ // (semantic leg) + SEM_LIMIT (BM25 leg) via RRF union (ops-i39b); the
180
+ // reranker still applies to its output further down regardless of which
181
+ // path produced `filteredResults`.
182
+ const rerankOn = isRerankEnabled();
183
+ const rerankTopN = getRerankTopN();
173
184
  if (hybrid) {
174
185
  // ─── BM25 + union-RRF hybrid path (ops-i39b) ─────────────────────────
175
186
  // 1. Semantic candidates via HNSW (unchanged fetch). 2. BM25 lexical pass
@@ -284,8 +295,10 @@ export class SemanticSearch extends Resource {
284
295
  }
285
296
  }
286
297
  else if (qEmb) {
287
- // ─── HNSW vector search path ───────────────────────────────────────────
288
- const candidateLimit = limit * CANDIDATE_MULTIPLIER;
298
+ // ─── HNSW vector search path (legacy, hybrid flag OFF) ────────────────
299
+ const candidateLimit = rerankOn
300
+ ? Math.max(limit * CANDIDATE_MULTIPLIER, rerankTopN)
301
+ : limit * CANDIDATE_MULTIPLIER;
289
302
  const query = {
290
303
  sort: { attribute: "embedding", target: qEmb, distance: "cosine" },
291
304
  select: ["id", "agentId", "content", "contentHash", "visibility", "tags", "durability",
@@ -387,7 +400,32 @@ export class SemanticSearch extends Resource {
387
400
  if (minScore > 0) {
388
401
  filteredResults = filteredResults.filter((r) => r._score >= minScore);
389
402
  }
390
- filteredResults.sort((a, b) => b._score - a._score);
403
+ // ─── Cross-encoder rerank (best-effort, fail-open to vector order) ───────
404
+ // Re-scores query+candidate TOGETHER (cross-attention the pooled embedding
405
+ // can't do) and reorders before the final slice. Reorders whatever
406
+ // `filteredResults` the retrieval stage above produced — legacy HNSW-only
407
+ // OR the BM25+union-RRF hybrid path (ops-i39b) — since both converge into
408
+ // the same `results`/`filteredResults` shape before this point; hybrid
409
+ // retrieves+fuses, the reranker only reorders the fused set. Still gated
410
+ // on `qEmb` (an embedding was actually generated); the pure keyword-only
411
+ // fallback (no qEmb at all) is untouched either way. The reranker
412
+ // overwrites `_score` with the rerank score (so margin measurement reads it)
413
+ // and preserves the semantic score as `_semScore`; `_rawScore` is left as-is
414
+ // so recall-bench's scoring:"raw" path stays reproducible. On init failure,
415
+ // timeout, or any throw, rerankCandidates returns the input UNCHANGED and we
416
+ // fall through to the existing vector-order sort — recall is never blocked.
417
+ if (rerankOn && qEmb && q && filteredResults.length >= getRerankMinCandidates()) {
418
+ // Pre-sort by vector order so the topN fed to the reranker is the most
419
+ // semantically-promising slice (filteredResults isn't sorted yet here).
420
+ filteredResults.sort((a, b) => b._score - a._score);
421
+ filteredResults = await rerankCandidates(String(q), filteredResults, {
422
+ topN: rerankTopN,
423
+ budgetMs: getRerankBudgetMs(),
424
+ });
425
+ }
426
+ else {
427
+ filteredResults.sort((a, b) => b._score - a._score);
428
+ }
391
429
  const topResults = filteredResults.slice(0, limit);
392
430
  // Async hit tracking — don't block the response
393
431
  const now = new Date().toISOString();
@@ -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,62 @@
1
+ // ─── Conservative near-duplicate detection primitives (Harper-free) ─────────
2
+ // Memory-integrity fix (#526/#548 field regressions). This module is
3
+ // deliberately Harper-free — same rationale as ./bm25.ts and ./scoring.ts —
4
+ // so the cosine+Jaccard co-gate math is unit-testable directly against the
5
+ // SHIPPED code without a live Harper. The DB-touching top-1-cosine lookup
6
+ // (needs `databases`) lives in ./Memory.ts, built on top of these primitives.
7
+ //
8
+ // ── THE INVARIANT ────────────────────────────────────────────────────────────
9
+ // This module NEVER decides whether to write. It only scores a candidate
10
+ // match. Callers (Memory.post / Memory.put) always write the new record; a
11
+ // "conservative match" is surfaced to the caller as a SIGNAL on the response
12
+ // (deduplicated/matchedId/matchConfidence), never as a reason to skip the
13
+ // write. That was the #526 bug: two topically-close but DISTINCT findings —
14
+ // one about replication route-directionality, one about DDL/schema
15
+ // replication — and the second was silently dropped because raw cosine alone
16
+ // flagged it as a "duplicate" of the first. Requiring BOTH cosine AND lexical
17
+ // (Jaccard token-overlap) to clear their thresholds against the SAME single
18
+ // top-cosine candidate catches true near-dups while sparing topic collisions
19
+ // (high cosine, low lexical) — and even then, only ever as a non-suppressing
20
+ // signal.
21
+ import { tokenize } from "./bm25.js";
22
+ /** Default raw-cosine similarity threshold for a candidate to even be considered. */
23
+ export const DEDUP_COSINE_THRESHOLD_DEFAULT = 0.95;
24
+ /** Default Jaccard token-overlap threshold (of the top cosine candidate). */
25
+ export const DEDUP_LEXICAL_THRESHOLD_DEFAULT = 0.5;
26
+ /** Below this content length, similarity scoring is unreliable ("ok" would
27
+ * match "ok" trivially) — the dedup gate is bypassed entirely. */
28
+ export const DEDUP_MIN_CONTENT_LENGTH = 20;
29
+ /** Jaccard similarity of two token sets: |A∩B| / |A∪B|. An empty side yields 0
30
+ * (no signal — never treated as "fully similar" by vacuous set equality). */
31
+ export function jaccardSimilarity(tokensA, tokensB) {
32
+ const a = new Set(tokensA);
33
+ const b = new Set(tokensB);
34
+ if (a.size === 0 || b.size === 0)
35
+ return 0;
36
+ let intersection = 0;
37
+ for (const t of a)
38
+ if (b.has(t))
39
+ intersection++;
40
+ const union = a.size + b.size - intersection;
41
+ return union === 0 ? 0 : intersection / union;
42
+ }
43
+ /**
44
+ * Conservative match = cosine AND lexical BOTH clear their thresholds.
45
+ * Both gates are required — a topic collision (high cosine, low lexical: two
46
+ * DIFFERENT findings that merely share a topic/vocabulary) must NOT be
47
+ * flagged as a duplicate. Only a true near-duplicate (high cosine AND high
48
+ * lexical overlap) is flagged.
49
+ */
50
+ export function isConservativeMatch(cosine, lexical, cosineThreshold = DEDUP_COSINE_THRESHOLD_DEFAULT, lexicalThreshold = DEDUP_LEXICAL_THRESHOLD_DEFAULT) {
51
+ return cosine >= cosineThreshold && lexical >= lexicalThreshold;
52
+ }
53
+ /** Compute the {cosine, lexical} confidence pair for a candidate against the
54
+ * new content's text, using the shared bm25 tokenizer. Rounded to 3dp to
55
+ * match the existing _score rounding convention (SemanticSearch.ts). */
56
+ export function computeMatchConfidence(newContent, candidateContent, cosine) {
57
+ const lexical = jaccardSimilarity(tokenize(newContent), tokenize(candidateContent || ""));
58
+ return {
59
+ cosine: Math.round(cosine * 1000) / 1000,
60
+ lexical: Math.round(lexical * 1000) / 1000,
61
+ };
62
+ }
@@ -2,6 +2,7 @@ import { Resource, databases } from "@harperfast/harper";
2
2
  import { promises as fsp } from "node:fs";
3
3
  import { homedir, platform } from "node:os";
4
4
  import { join } from "node:path";
5
+ import { getRerankStatus } from "./rerank-provider.js";
5
6
  const db = databases;
6
7
  const redactHome = (p) => {
7
8
  const home = homedir();
@@ -515,6 +516,22 @@ export class HealthDetail extends Resource {
515
516
  catch {
516
517
  stats.bridges = null;
517
518
  }
519
+ // ── Reranker ──
520
+ // Cross-encoder rerank stage. Reports flag/model/mode + live counters so we
521
+ // can see if it's silently degrading (fallbackCount climbing) in prod.
522
+ try {
523
+ const rr = getRerankStatus();
524
+ stats.rerank = rr;
525
+ if (rr.enabled && rr.state === "failed") {
526
+ warnings.push({ level: "warn", message: `reranker enabled but unavailable: ${rr.error ?? "init failed"} — recall falling back to vector order` });
527
+ }
528
+ if (rr.enabled && rr.rerankCount > 0 && rr.fallbackCount > rr.rerankCount) {
529
+ warnings.push({ level: "warn", message: `reranker falling back more than reranking (${rr.fallbackCount} fallbacks / ${rr.rerankCount} reranks) — check latency budget / topN` });
530
+ }
531
+ }
532
+ catch {
533
+ stats.rerank = null;
534
+ }
518
535
  // ── Warnings ──
519
536
  stats.warnings = warnings;
520
537
  // ── Process info ──
@@ -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
+ }