@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.
- package/dist/resources/AdminMemory.js +6 -1
- package/dist/resources/AgentSeed.js +8 -1
- package/dist/resources/IngestEvents.js +7 -1
- package/dist/resources/Memory.js +312 -32
- package/dist/resources/MemoryBootstrap.js +35 -0
- package/dist/resources/OAuth.js +30 -4
- package/dist/resources/OrgEventCatchup.js +14 -6
- package/dist/resources/SemanticSearch.js +41 -3
- package/dist/resources/WorkspaceLatest.js +15 -6
- package/dist/resources/dedup.js +62 -0
- package/dist/resources/health.js +17 -0
- 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 +417 -0
- package/dist/resources/memory-bootstrap-lib.js +42 -0
- package/dist/resources/rerank-provider.js +361 -0
- package/package.json +3 -1
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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,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
|
+
}
|
package/dist/resources/health.js
CHANGED
|
@@ -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
|
+
}
|