@tpsdev-ai/flair 0.32.0 → 0.34.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/README.md +64 -64
- package/SECURITY.md +7 -0
- package/config.yaml +34 -0
- package/dist/cli.js +659 -111
- package/dist/component-env.js +286 -0
- package/dist/deploy.js +190 -3
- package/dist/doctor-client.js +357 -7
- package/dist/hook-install.js +39 -9
- package/dist/lib/auth-resolve.js +85 -2
- package/dist/lib/launchd-management.js +328 -0
- package/dist/lib/mcp-enable.js +19 -0
- package/dist/resources/AdminInstance.js +20 -2
- package/dist/resources/Memory.js +24 -2
- package/dist/resources/OAuth.js +41 -25
- package/dist/resources/auth-middleware.js +26 -0
- package/dist/resources/dcr-gate.js +194 -0
- package/dist/resources/in-process-api.js +5 -1
- package/dist/resources/mcp-handler.js +91 -4
- package/dist/resources/mcp-oauth.js +89 -7
- package/dist/resources/mcp-tools.js +40 -0
- package/dist/resources/oauth-discovery.js +242 -0
- package/dist/resources/oauth-wellknown.js +111 -0
- package/dist/resources/rate-limit.js +400 -0
- package/docs/auth.md +122 -5
- package/docs/deploying-on-fabric.md +35 -2
- package/docs/deployment.md +1 -1
- package/docs/embedding-in-a-harper-app.md +6 -1
- package/docs/hosted-on-fabric.md +1 -1
- package/docs/mcp-clients.md +28 -4
- package/docs/quickstart.md +29 -4
- package/docs/the-team.md +8 -4
- package/docs/troubleshooting.md +37 -1
- package/package.json +1 -1
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dcr-gate.ts — who may register an OAuth client (RFC 7591 Dynamic Client
|
|
3
|
+
* Registration) on flair's own authorization server.
|
|
4
|
+
*
|
|
5
|
+
* ── The state this replaces ─────────────────────────────────────────────────
|
|
6
|
+
* `OAuthRegister.allowCreate()` returned `true` unconditionally and the only
|
|
7
|
+
* gate in `post()` was a redirect-URI host match. On a publicly-reachable
|
|
8
|
+
* instance that means anyone can create rows in `OAuthClient` — a durable,
|
|
9
|
+
* replicated table — as fast as they can send requests, and every one of those
|
|
10
|
+
* rows is a client_id that will subsequently be honoured by `/OAuthAuthorize`.
|
|
11
|
+
*
|
|
12
|
+
* ── Registration is now OFF unless an operator turns it on ──────────────────
|
|
13
|
+
* `FLAIR_OAUTH_DCR_TOKEN` is the whole interface. Absent — which is every
|
|
14
|
+
* install that has not deliberately opted in — `POST /OAuthRegister` refuses,
|
|
15
|
+
* and `/OAuthMetadata` and `/.well-known/oauth-authorization-server` stop
|
|
16
|
+
* advertising a `registration_endpoint`, because advertising one that refuses
|
|
17
|
+
* everything is a discovery document that lies.
|
|
18
|
+
*
|
|
19
|
+
* ── Why ONE variable and not a mode enum ────────────────────────────────────
|
|
20
|
+
* There is deliberately no `FLAIR_OAUTH_DCR=open`, and no way to enable
|
|
21
|
+
* registration without also supplying the credential that guards it. Enabling
|
|
22
|
+
* and crededentialling are the SAME ACT, so the "on, and open to the internet"
|
|
23
|
+
* state is not reachable by forgetting an argument — it does not exist in the
|
|
24
|
+
* configuration space at all. A mode enum plus an optional token would put that
|
|
25
|
+
* state one typo away, and the difference between the two designs is only
|
|
26
|
+
* visible on the day someone makes the typo.
|
|
27
|
+
*
|
|
28
|
+
* That is also why a token shorter than `MIN_INITIAL_ACCESS_TOKEN_LEN` disables
|
|
29
|
+
* registration rather than enabling it weakly. A four-character shared secret on
|
|
30
|
+
* an unauthenticated public endpoint is nearer to open than to closed, and a
|
|
31
|
+
* control that silently degrades to almost-off is worse than one that is off,
|
|
32
|
+
* because only one of the two is visible. The refusal is logged, by variable
|
|
33
|
+
* NAME and minimum length — never the value.
|
|
34
|
+
*
|
|
35
|
+
* ── Why a header and not `Authorization: Bearer` ────────────────────────────
|
|
36
|
+
* RFC 7591 s3.1 presents the initial access token as a Bearer token. That
|
|
37
|
+
* channel does not work here, and this is measured rather than assumed: Harper's
|
|
38
|
+
* own auth layer claims every `Authorization: Bearer ...` header for itself and
|
|
39
|
+
* validates it as a Harper OPERATION token, so a Bearer-carrying request to
|
|
40
|
+
* `/OAuthRegister` is answered `401 {"error":"invalid token"}` before any code
|
|
41
|
+
* in resources/ runs. (Probed against a live instance: no header -> 200,
|
|
42
|
+
* `Bearer <anything>` -> 401, custom header -> 200. Same mechanism documented in
|
|
43
|
+
* resources/oauth-wellknown.ts's header for the REST surface.) So the token
|
|
44
|
+
* arrives in `X-Flair-Initial-Access-Token`, which does reach the handler.
|
|
45
|
+
*
|
|
46
|
+
* ── This is not the same surface as flair#756's DCR removal ─────────────────
|
|
47
|
+
* flair#756 disabled Dynamic Client Registration on the `@harperfast/oauth`
|
|
48
|
+
* plugin's authorization server (`/oauth/mcp/register`) in favour of CIMD, and
|
|
49
|
+
* deleted the gate-token machinery there. This module is about flair's OWN
|
|
50
|
+
* OAuth 2.1 AS in resources/OAuth.ts, a separate endpoint that was left open.
|
|
51
|
+
* The posture is the same one #756 settled on — registration is closed unless
|
|
52
|
+
* an operator deliberately opens it — arrived at through the mechanism this
|
|
53
|
+
* endpoint can actually support.
|
|
54
|
+
*/
|
|
55
|
+
import { timingSafeEqual } from "node:crypto";
|
|
56
|
+
/** The header the initial access token arrives in. See the module header. */
|
|
57
|
+
export const INITIAL_ACCESS_TOKEN_HEADER = "x-flair-initial-access-token";
|
|
58
|
+
/**
|
|
59
|
+
* Shortest initial access token that will enable registration.
|
|
60
|
+
*
|
|
61
|
+
* The token guards an unauthenticated, publicly-reachable endpoint, so its only
|
|
62
|
+
* defence is length. 32 characters of anything reasonable clears any offline
|
|
63
|
+
* guessing concern; the per-IP rate limit on `/OAuthRegister` covers online
|
|
64
|
+
* guessing. Anything shorter is refused outright rather than accepted weakly.
|
|
65
|
+
*/
|
|
66
|
+
export const MIN_INITIAL_ACCESS_TOKEN_LEN = 32;
|
|
67
|
+
/**
|
|
68
|
+
* Longest accepted initial access token.
|
|
69
|
+
*
|
|
70
|
+
* A bound is needed because the constant-time comparison below works over
|
|
71
|
+
* fixed-width buffers; it is set far above any plausible token (508 bytes is
|
|
72
|
+
* ~15x a 32-byte base64 secret) so it constrains nothing real. A configured
|
|
73
|
+
* value outside the range disables registration and says so, rather than
|
|
74
|
+
* failing every comparison silently for a reason no operator could deduce.
|
|
75
|
+
*/
|
|
76
|
+
export const MAX_INITIAL_ACCESS_TOKEN_LEN = 508;
|
|
77
|
+
let warnedBadLength = false;
|
|
78
|
+
/** Test-only: forget the one-shot bad-length warning. */
|
|
79
|
+
export function __resetDcrWarningForTest() {
|
|
80
|
+
warnedBadLength = false;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* The configured initial access token, or undefined when registration is off.
|
|
84
|
+
*
|
|
85
|
+
* Undefined covers all three of: variable unset, variable empty, and variable
|
|
86
|
+
* too short to be a credential. Callers therefore cannot accidentally treat
|
|
87
|
+
* "misconfigured" as "open" — there is one value that means enabled and it is a
|
|
88
|
+
* usable token.
|
|
89
|
+
*/
|
|
90
|
+
export function initialAccessToken() {
|
|
91
|
+
const raw = (process.env.FLAIR_OAUTH_DCR_TOKEN ?? "").trim();
|
|
92
|
+
if (raw === "")
|
|
93
|
+
return undefined;
|
|
94
|
+
if (raw.length < MIN_INITIAL_ACCESS_TOKEN_LEN || Buffer.byteLength(raw, "utf8") > MAX_INITIAL_ACCESS_TOKEN_LEN) {
|
|
95
|
+
if (!warnedBadLength) {
|
|
96
|
+
warnedBadLength = true;
|
|
97
|
+
console.error(`[oauth-dcr] FLAIR_OAUTH_DCR_TOKEN must be between ${MIN_INITIAL_ACCESS_TOKEN_LEN} and ` +
|
|
98
|
+
`${MAX_INITIAL_ACCESS_TOKEN_LEN} characters — dynamic client registration stays DISABLED.`);
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
return raw;
|
|
103
|
+
}
|
|
104
|
+
/** Is `POST /OAuthRegister` open for business on this instance? */
|
|
105
|
+
export function dcrEnabled() {
|
|
106
|
+
return initialAccessToken() !== undefined;
|
|
107
|
+
}
|
|
108
|
+
/** Width of the comparison buffers: a 4-byte length prefix plus the token bytes. */
|
|
109
|
+
const COMPARE_WIDTH = MAX_INITIAL_ACCESS_TOKEN_LEN + 4;
|
|
110
|
+
/**
|
|
111
|
+
* Encode a value into a fixed-width buffer as `<uint32 byte length><bytes><zero
|
|
112
|
+
* padding>`, or null if it does not fit.
|
|
113
|
+
*
|
|
114
|
+
* The length prefix is what makes zero-padding safe. Padding alone would make
|
|
115
|
+
* `"abc"` and `"abc\0"` compare equal, because both pad to the same bytes;
|
|
116
|
+
* prefixing the length means two buffers are equal exactly when the two inputs
|
|
117
|
+
* are the same length AND the same bytes.
|
|
118
|
+
*/
|
|
119
|
+
function fixedWidth(value) {
|
|
120
|
+
const bytes = Buffer.from(value, "utf8");
|
|
121
|
+
if (bytes.length > MAX_INITIAL_ACCESS_TOKEN_LEN)
|
|
122
|
+
return null;
|
|
123
|
+
const buf = Buffer.alloc(COMPARE_WIDTH);
|
|
124
|
+
buf.writeUInt32BE(bytes.length, 0);
|
|
125
|
+
bytes.copy(buf, 4);
|
|
126
|
+
return buf;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Does the presented token match the configured one?
|
|
130
|
+
*
|
|
131
|
+
* Both values are widened to the same fixed-size buffer and compared with
|
|
132
|
+
* `timingSafeEqual`. Equal width is not a convenience — it is the whole point:
|
|
133
|
+
*
|
|
134
|
+
* - `timingSafeEqual` THROWS on a length mismatch, so handing it the raw values
|
|
135
|
+
* would turn the configured token's length into an oracle (throw vs. false).
|
|
136
|
+
* - `===` on strings short-circuits at the first differing byte, so it leaks a
|
|
137
|
+
* prefix match through timing.
|
|
138
|
+
*
|
|
139
|
+
* Fixed-width buffers remove both, and the comparison touches every byte of the
|
|
140
|
+
* secret regardless of the input.
|
|
141
|
+
*
|
|
142
|
+
* DELIBERATELY NOT HASHED. An earlier revision digested both sides to get equal
|
|
143
|
+
* lengths. That works, but a hash of a credential sitting in an equality check
|
|
144
|
+
* reads — to a human and to a static analyser alike — as password-at-rest
|
|
145
|
+
* storage, which invites the obvious "why isn't this a KDF" question. The honest
|
|
146
|
+
* answer is that a KDF is the wrong primitive here: this is an equality check on
|
|
147
|
+
* a high-entropy machine credential on an unauthenticated request path, so
|
|
148
|
+
* deliberate per-request computational cost would be handing an attacker a
|
|
149
|
+
* cheap amplification lever on the exact endpoint the rate limiter exists to
|
|
150
|
+
* protect. Padding gets the same constant-time property with no hash to explain
|
|
151
|
+
* and no cost to exploit.
|
|
152
|
+
*
|
|
153
|
+
* Returns false whenever registration is disabled, so a caller cannot reach the
|
|
154
|
+
* comparison at all.
|
|
155
|
+
*/
|
|
156
|
+
export function initialAccessTokenMatches(presented) {
|
|
157
|
+
const expected = initialAccessToken();
|
|
158
|
+
if (expected === undefined)
|
|
159
|
+
return false;
|
|
160
|
+
if (typeof presented !== "string" || presented === "")
|
|
161
|
+
return false;
|
|
162
|
+
const a = fixedWidth(presented);
|
|
163
|
+
const b = fixedWidth(expected);
|
|
164
|
+
// `b` cannot be null — initialAccessToken() already bounds the configured
|
|
165
|
+
// value — but null-checking both keeps this total rather than relying on that
|
|
166
|
+
// invariant holding in a future edit.
|
|
167
|
+
if (a === null || b === null)
|
|
168
|
+
return false;
|
|
169
|
+
return timingSafeEqual(a, b);
|
|
170
|
+
}
|
|
171
|
+
/** Read the initial access token off a Harper request. Never logs it. */
|
|
172
|
+
export function presentedInitialAccessToken(request) {
|
|
173
|
+
const raw = request?.headers?.get?.(INITIAL_ACCESS_TOKEN_HEADER) ??
|
|
174
|
+
request?.headers?.asObject?.[INITIAL_ACCESS_TOKEN_HEADER] ??
|
|
175
|
+
undefined;
|
|
176
|
+
if (typeof raw !== "string")
|
|
177
|
+
return undefined;
|
|
178
|
+
const trimmed = raw.trim();
|
|
179
|
+
return trimmed === "" ? undefined : trimmed;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The single decision point. `resources/OAuth.ts` calls this before it reads
|
|
183
|
+
* ANYTHING else off the request — before the redirect-URI check, before any
|
|
184
|
+
* write — so a caller who is not allowed to register learns nothing about the
|
|
185
|
+
* server's registration policy beyond the fact that they may not.
|
|
186
|
+
*/
|
|
187
|
+
export function decideRegistration(request) {
|
|
188
|
+
if (!dcrEnabled())
|
|
189
|
+
return { allowed: false, reason: "disabled" };
|
|
190
|
+
if (!initialAccessTokenMatches(presentedInitialAccessToken(request))) {
|
|
191
|
+
return { allowed: false, reason: "invalid_token" };
|
|
192
|
+
}
|
|
193
|
+
return { allowed: true };
|
|
194
|
+
}
|
|
@@ -327,6 +327,7 @@ class InternalAgentTable {
|
|
|
327
327
|
*/
|
|
328
328
|
export class Flair {
|
|
329
329
|
#server;
|
|
330
|
+
#adminHandle;
|
|
330
331
|
constructor(server) {
|
|
331
332
|
this.#server = server;
|
|
332
333
|
}
|
|
@@ -350,7 +351,10 @@ export class Flair {
|
|
|
350
351
|
// AdminHandle requires an agentId for attribution. We use a sentinel
|
|
351
352
|
// that makes the admin identity visible in audit logs. The caller
|
|
352
353
|
// should use a real admin agent id when possible.
|
|
353
|
-
|
|
354
|
+
// Cached: the getter returns the same handle on every access so
|
|
355
|
+
// flair.admin === flair.admin is true (flair#981).
|
|
356
|
+
this.#adminHandle ??= new AdminHandle(this.#server, "_admin");
|
|
357
|
+
return this.#adminHandle;
|
|
354
358
|
}
|
|
355
359
|
/**
|
|
356
360
|
* Internal operations — trusted, unattributed, unfiltered.
|
|
@@ -27,9 +27,92 @@ import { databases } from "harper";
|
|
|
27
27
|
import { randomBytes } from "node:crypto";
|
|
28
28
|
import { TOOLS, listToolDefs } from "./mcp-tools.js";
|
|
29
29
|
import { agentRecordIsAdmin } from "./agent-admin.js";
|
|
30
|
+
import { resolveVersion } from "./version.js";
|
|
30
31
|
// The MCP protocol revision we implement (initialize handshake).
|
|
31
32
|
const PROTOCOL_VERSION = "2025-06-18";
|
|
33
|
+
// ─── Body size cap ───────────────────────────────────────────────────────────
|
|
34
|
+
//
|
|
35
|
+
// flair#1033 — the /mcp handler reads the entire request body into memory with
|
|
36
|
+
// no size limit. Harper's HTTP layer imposes no cap on this path (the handler
|
|
37
|
+
// is registered via srv.http() which goes through Harper's own HTTP chain, not
|
|
38
|
+
// Fastify's 1 GB bodyLimit or the contentTypes handler's configurable 10 MB
|
|
39
|
+
// default). /mcp is the first surface reachable by an open population of OAuth
|
|
40
|
+
// clients rather than by Ed25519 agents we provisioned, so the reachability
|
|
41
|
+
// story is materially different from the identical pattern on any other route.
|
|
42
|
+
//
|
|
43
|
+
// 256 KB is ~100x headroom over any legitimate MCP JSON-RPC request (a
|
|
44
|
+
// memory_store with a large content field is a few KB; even a batch of 100
|
|
45
|
+
// would be well under this). It is small enough that an attacker cannot
|
|
46
|
+
// meaningfully consume memory through this path.
|
|
47
|
+
const MAX_MCP_BODY_SIZE = 256 * 1024; // 256 KB
|
|
32
48
|
const JSON_HEADERS = { "content-type": "application/json" };
|
|
49
|
+
// ─── Body size cap helpers ───────────────────────────────────────────────────
|
|
50
|
+
/**
|
|
51
|
+
* Read the request body into a string, enforcing a hard byte cap.
|
|
52
|
+
*
|
|
53
|
+
* Two-phase enforcement:
|
|
54
|
+
* 1. Content-Length check (reject before reading a single byte when the
|
|
55
|
+
* client declares an oversized body).
|
|
56
|
+
* 2. Streaming read with a cap (catches chunked transfer encoding where
|
|
57
|
+
* Content-Length is absent, and a client that lies about its declared
|
|
58
|
+
* length).
|
|
59
|
+
*
|
|
60
|
+
* Handles three request shapes:
|
|
61
|
+
* - Production: Harper Request with async-iterable `request.body` (RequestBody
|
|
62
|
+
* wrapping Node IncomingMessage).
|
|
63
|
+
* - Test doubles: `request.text()` as a function (returns pre-built string),
|
|
64
|
+
* or `request.body` as a plain string.
|
|
65
|
+
*
|
|
66
|
+
* Throws an error with `code: "BODY_TOO_LARGE"` when the cap is exceeded, so
|
|
67
|
+
* the caller can distinguish a size rejection from a parse failure.
|
|
68
|
+
*/
|
|
69
|
+
async function readBodyCapped(request, maxBytes) {
|
|
70
|
+
// Phase 1: trust-but-verify the declared Content-Length.
|
|
71
|
+
const contentLength = request?.headers?.get?.("content-length");
|
|
72
|
+
if (contentLength != null) {
|
|
73
|
+
const declared = Number(contentLength);
|
|
74
|
+
if (!Number.isFinite(declared) || declared < 0) {
|
|
75
|
+
throw Object.assign(new Error(`invalid Content-Length: ${contentLength}`), { code: "BODY_TOO_LARGE" });
|
|
76
|
+
}
|
|
77
|
+
if (declared > maxBytes) {
|
|
78
|
+
throw Object.assign(new Error(`request body too large: ${declared} bytes exceeds ${maxBytes}-byte limit`), { code: "BODY_TOO_LARGE" });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Phase 2: read with a cap.
|
|
82
|
+
const body = request.body;
|
|
83
|
+
// Test double: body is already a plain string.
|
|
84
|
+
if (typeof body === "string") {
|
|
85
|
+
if (Buffer.byteLength(body) > maxBytes) {
|
|
86
|
+
throw Object.assign(new Error(`request body too large: exceeds ${maxBytes}-byte limit`), { code: "BODY_TOO_LARGE" });
|
|
87
|
+
}
|
|
88
|
+
return body;
|
|
89
|
+
}
|
|
90
|
+
// Test double: request.text() is provided as a function (returns a string).
|
|
91
|
+
if (typeof request.text === "function") {
|
|
92
|
+
const text = await request.text();
|
|
93
|
+
if (typeof text === "string" && Buffer.byteLength(text) > maxBytes) {
|
|
94
|
+
throw Object.assign(new Error(`request body too large: exceeds ${maxBytes}-byte limit`), { code: "BODY_TOO_LARGE" });
|
|
95
|
+
}
|
|
96
|
+
return text;
|
|
97
|
+
}
|
|
98
|
+
// Production: Harper Request.body is a RequestBody (async-iterable, wraps
|
|
99
|
+
// Node IncomingMessage). Read chunk by chunk with a running cap.
|
|
100
|
+
if (body && typeof body[Symbol.asyncIterator] === "function") {
|
|
101
|
+
const chunks = [];
|
|
102
|
+
let total = 0;
|
|
103
|
+
for await (const chunk of body) {
|
|
104
|
+
const buf = typeof chunk === "string" ? Buffer.from(chunk) : chunk;
|
|
105
|
+
total += buf.length;
|
|
106
|
+
if (total > maxBytes) {
|
|
107
|
+
throw Object.assign(new Error(`request body too large: exceeds ${maxBytes}-byte limit`), { code: "BODY_TOO_LARGE" });
|
|
108
|
+
}
|
|
109
|
+
chunks.push(buf);
|
|
110
|
+
}
|
|
111
|
+
return Buffer.concat(chunks).toString("utf-8");
|
|
112
|
+
}
|
|
113
|
+
// Fallback: body is absent or an unrecognised shape.
|
|
114
|
+
return String(body ?? "");
|
|
115
|
+
}
|
|
33
116
|
// ─── JSON-RPC helpers ────────────────────────────────────────────────────────
|
|
34
117
|
function rpcResult(id, result) {
|
|
35
118
|
return { status: 200, headers: JSON_HEADERS, body: JSON.stringify({ jsonrpc: "2.0", id, result }) };
|
|
@@ -195,13 +278,17 @@ export async function mcpHandler(request) {
|
|
|
195
278
|
if (method !== "POST") {
|
|
196
279
|
return rpcError(null, -32600, "method not allowed: /mcp accepts JSON-RPC POST only", 405);
|
|
197
280
|
}
|
|
198
|
-
// Parse the JSON-RPC body. Harper's Request wraps a Node
|
|
281
|
+
// Parse the JSON-RPC body with a size cap. Harper's Request wraps a Node
|
|
282
|
+
// stream — read text, but never unbounded.
|
|
199
283
|
let msg;
|
|
200
284
|
try {
|
|
201
|
-
const text =
|
|
285
|
+
const text = await readBodyCapped(request, MAX_MCP_BODY_SIZE);
|
|
202
286
|
msg = typeof text === "string" ? JSON.parse(text) : text;
|
|
203
287
|
}
|
|
204
|
-
catch {
|
|
288
|
+
catch (err) {
|
|
289
|
+
if (err?.code === "BODY_TOO_LARGE") {
|
|
290
|
+
return rpcError(null, -32000, err.message, 413);
|
|
291
|
+
}
|
|
205
292
|
return rpcError(null, -32700, "parse error: invalid JSON");
|
|
206
293
|
}
|
|
207
294
|
if (!msg || typeof msg !== "object" || msg.jsonrpc !== "2.0" || typeof msg.method !== "string") {
|
|
@@ -213,7 +300,7 @@ export async function mcpHandler(request) {
|
|
|
213
300
|
return rpcResult(id, {
|
|
214
301
|
protocolVersion: PROTOCOL_VERSION,
|
|
215
302
|
capabilities: { tools: {} },
|
|
216
|
-
serverInfo: { name: "flair", version:
|
|
303
|
+
serverInfo: { name: "flair", version: resolveVersion() },
|
|
217
304
|
});
|
|
218
305
|
// Notifications (no id) — acknowledge with 202-ish empty 200; MCP clients send
|
|
219
306
|
// `notifications/initialized` after initialize.
|
|
@@ -19,14 +19,73 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import * as harper from "harper";
|
|
21
21
|
import { mcpOAuthEnabled, mcpAuthConfig } from "./mcp-oauth-flag.js";
|
|
22
|
+
import { checkMcpRateLimit } from "./rate-limit.js";
|
|
23
|
+
/**
|
|
24
|
+
* Initial value: no route has been registered yet, which is literally true until
|
|
25
|
+
* `registerMcpOAuthRoute` runs — a reader between module load and registration
|
|
26
|
+
* would get a 404 from `/mcp`, so reporting "not mounted" is accurate rather
|
|
27
|
+
* than merely safe. It also stays correct for an embedder that sets
|
|
28
|
+
* FLAIR_MCP_NO_AUTOSTART and never calls the registration function.
|
|
29
|
+
*/
|
|
30
|
+
let routeState = {
|
|
31
|
+
mounted: false,
|
|
32
|
+
status: "Not mounted",
|
|
33
|
+
reason: "MCP route registration has not run.",
|
|
34
|
+
};
|
|
35
|
+
/** Read the mount decision the router recorded. */
|
|
36
|
+
export function mcpRouteState() {
|
|
37
|
+
return routeState;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Record a decision and return its `mounted` value. Every `return` in
|
|
41
|
+
* `registerMcpOAuthRoute` goes through here, so a branch cannot decide the
|
|
42
|
+
* route's fate without publishing that decision.
|
|
43
|
+
*/
|
|
44
|
+
function decide(state) {
|
|
45
|
+
routeState = state;
|
|
46
|
+
return state.mounted;
|
|
47
|
+
}
|
|
22
48
|
async function defaultLoadWithMCPAuth() {
|
|
23
49
|
// Dynamic import so the dep is only required when the surface is enabled.
|
|
24
50
|
const mod = (await import("@harperfast/oauth"));
|
|
25
51
|
return mod.withMCPAuth;
|
|
26
52
|
}
|
|
53
|
+
/**
|
|
54
|
+
* Wrap the /mcp handler in the per-subject rate limit.
|
|
55
|
+
*
|
|
56
|
+
* Placed INSIDE `withMCPAuth` — i.e. the guard runs first and this runs second —
|
|
57
|
+
* so the key is the RS256-verified `sub` from the token rather than a network
|
|
58
|
+
* address. That is the identity worth limiting on for an authenticated surface:
|
|
59
|
+
* it is the authorization server's own assertion, it survives the caller
|
|
60
|
+
* changing address, and it is exactly the thing "a valid token can hammer the
|
|
61
|
+
* tools" is about. Authentication is not a rate limit.
|
|
62
|
+
*
|
|
63
|
+
* The consequence of that placement, stated rather than left implicit: requests
|
|
64
|
+
* bearing an INVALID token are rejected by `withMCPAuth` before this runs and so
|
|
65
|
+
* are not counted here. Those cost one JWT verification against a locally-cached
|
|
66
|
+
* key and touch no flair table or tool, which is a materially cheaper path than
|
|
67
|
+
* a tool call — but it is not zero, and it is not throttled at this layer.
|
|
68
|
+
*
|
|
69
|
+
* Exported for tests: the limiter's behaviour is asserted directly on this
|
|
70
|
+
* wrapper, without needing the plugin present.
|
|
71
|
+
*/
|
|
72
|
+
export function rateLimitedMcpHandler(handler) {
|
|
73
|
+
return async (request) => {
|
|
74
|
+
const limited = checkMcpRateLimit(request);
|
|
75
|
+
if (limited)
|
|
76
|
+
return limited;
|
|
77
|
+
return handler(request);
|
|
78
|
+
};
|
|
79
|
+
}
|
|
27
80
|
export async function registerMcpOAuthRoute(deps = {}) {
|
|
28
|
-
if (!mcpOAuthEnabled())
|
|
29
|
-
|
|
81
|
+
if (!mcpOAuthEnabled()) {
|
|
82
|
+
// OFF → no route, no import, no side effects.
|
|
83
|
+
return decide({
|
|
84
|
+
mounted: false,
|
|
85
|
+
status: "Not enabled",
|
|
86
|
+
reason: "Set FLAIR_MCP_OAUTH=1 (and an issuer) to serve MCP over HTTP.",
|
|
87
|
+
});
|
|
88
|
+
}
|
|
30
89
|
const config = mcpAuthConfig();
|
|
31
90
|
if (!config) {
|
|
32
91
|
// Flag on but issuer unset → we cannot safely pin iss/aud. Do NOT mount an
|
|
@@ -34,7 +93,11 @@ export async function registerMcpOAuthRoute(deps = {}) {
|
|
|
34
93
|
// is the clearer signal). Log and bail — the operator must set FLAIR_MCP_ISSUER.
|
|
35
94
|
console.error("[mcp-oauth] FLAIR_MCP_OAUTH is on but no issuer configured " +
|
|
36
95
|
"(set FLAIR_MCP_ISSUER or FLAIR_PUBLIC_URL) — /mcp NOT mounted.");
|
|
37
|
-
return
|
|
96
|
+
return decide({
|
|
97
|
+
mounted: false,
|
|
98
|
+
status: "Not mounted",
|
|
99
|
+
reason: "FLAIR_MCP_OAUTH is on but no issuer is configured — set FLAIR_MCP_ISSUER (or FLAIR_PUBLIC_URL).",
|
|
100
|
+
});
|
|
38
101
|
}
|
|
39
102
|
let withMCPAuth;
|
|
40
103
|
try {
|
|
@@ -42,11 +105,22 @@ export async function registerMcpOAuthRoute(deps = {}) {
|
|
|
42
105
|
}
|
|
43
106
|
catch (err) {
|
|
44
107
|
console.error("[mcp-oauth] @harperfast/oauth not available — /mcp NOT mounted: " + (err?.message ?? err));
|
|
45
|
-
|
|
108
|
+
// The underlying error text stays in the log rather than being carried into
|
|
109
|
+
// an operator-facing string: it is arbitrary text from a dependency, and the
|
|
110
|
+
// admin page is HTML.
|
|
111
|
+
return decide({
|
|
112
|
+
mounted: false,
|
|
113
|
+
status: "Not mounted",
|
|
114
|
+
reason: "The @harperfast/oauth plugin could not be loaded — see the server log.",
|
|
115
|
+
});
|
|
46
116
|
}
|
|
47
117
|
if (typeof withMCPAuth !== "function") {
|
|
48
118
|
console.error("[mcp-oauth] @harperfast/oauth has no withMCPAuth export — /mcp NOT mounted.");
|
|
49
|
-
return
|
|
119
|
+
return decide({
|
|
120
|
+
mounted: false,
|
|
121
|
+
status: "Not mounted",
|
|
122
|
+
reason: "The @harperfast/oauth plugin has no withMCPAuth export — see the server log.",
|
|
123
|
+
});
|
|
50
124
|
}
|
|
51
125
|
// Resolve the handler lazily (injected in tests; real module otherwise) — see
|
|
52
126
|
// the top-of-file note on why it isn't a static import.
|
|
@@ -60,11 +134,11 @@ export async function registerMcpOAuthRoute(deps = {}) {
|
|
|
60
134
|
// wrapper's iss/aud checks match the minted tokens even if this component
|
|
61
135
|
// resolves a different node_modules copy of the plugin (docs/mcp-oauth.md
|
|
62
136
|
// §"Using withMCPAuth from a different component").
|
|
63
|
-
srv.http(withMCPAuth(handler, {
|
|
137
|
+
srv.http(withMCPAuth(rateLimitedMcpHandler(handler), {
|
|
64
138
|
getConfig: () => mcpAuthConfig(),
|
|
65
139
|
}), { urlPath: "/mcp" });
|
|
66
140
|
console.error(`[mcp-oauth] /mcp mounted (OAuth-guarded); issuer=${config.issuer}`);
|
|
67
|
-
return true;
|
|
141
|
+
return decide({ mounted: true });
|
|
68
142
|
}
|
|
69
143
|
// Fire-and-forget at module load. Any failure is contained inside
|
|
70
144
|
// registerMcpOAuthRoute (it logs and returns) so it can never crash flair boot.
|
|
@@ -81,6 +155,14 @@ export async function registerMcpOAuthRoute(deps = {}) {
|
|
|
81
155
|
// deployment.)
|
|
82
156
|
if (process.env.FLAIR_MCP_NO_AUTOSTART == null) {
|
|
83
157
|
void registerMcpOAuthRoute().catch((err) => {
|
|
158
|
+
// A throw escaping registerMcpOAuthRoute means the mount never happened, so
|
|
159
|
+
// record that too — otherwise mcpRouteState() would keep reporting whatever
|
|
160
|
+
// the last completed decision was.
|
|
161
|
+
decide({
|
|
162
|
+
mounted: false,
|
|
163
|
+
status: "Not mounted",
|
|
164
|
+
reason: "MCP route registration failed — see the server log.",
|
|
165
|
+
});
|
|
84
166
|
console.error("[mcp-oauth] route registration failed (surface not mounted): " + (err?.message ?? err));
|
|
85
167
|
});
|
|
86
168
|
}
|
|
@@ -159,6 +159,37 @@ async function memoryStore(agent, args) {
|
|
|
159
159
|
// id post-commit through the shared usage ledger).
|
|
160
160
|
if (Array.isArray(args?.usedMemoryIds))
|
|
161
161
|
body.usedMemoryIds = args.usedMemoryIds;
|
|
162
|
+
// flair#991 writer-controlled sharing intent. Forwarded ONLY when the caller
|
|
163
|
+
// actually supplied it, so an omitted visibility delegates a byte-identical
|
|
164
|
+
// body and Memory.post() applies its durability-keyed default.
|
|
165
|
+
//
|
|
166
|
+
// ── Why an unrecognized value is REJECTED, not dropped and not passed on ──
|
|
167
|
+
// `visibility` is a free-form String in schemas/memory.graphql, and the read
|
|
168
|
+
// scope asks `isPrivateVisibility()` — an exact match on the literal
|
|
169
|
+
// "private" — so EVERY other string, typos included, reads as non-private
|
|
170
|
+
// and is returned to every agent on the instance. Both of the softer
|
|
171
|
+
// options therefore fail in the unsafe direction:
|
|
172
|
+
// - forwarding it: `visibility: "prvate"` persists a row the caller
|
|
173
|
+
// believes is owner-only and that every agent can in fact read;
|
|
174
|
+
// - silently dropping it: falls back to the durability-keyed default,
|
|
175
|
+
// which for a permanent/persistent write is `shared` — same outcome,
|
|
176
|
+
// with no argument left in the record to explain it.
|
|
177
|
+
// A misspelled argument must never widen who can read a memory, so the tool
|
|
178
|
+
// call fails and says so. The allowlist is deliberately not derived from
|
|
179
|
+
// isPrivateVisibility(): that predicate must stay "is it exactly private"
|
|
180
|
+
// for the no-visibility-field migration invariant (see
|
|
181
|
+
// resources/memory-visibility.ts), which is a READ-side rule and cannot
|
|
182
|
+
// double as a WRITE-side allowlist.
|
|
183
|
+
if (args?.visibility !== undefined && args?.visibility !== null) {
|
|
184
|
+
if (args.visibility !== "private" && args.visibility !== "shared") {
|
|
185
|
+
return {
|
|
186
|
+
error: "invalid_visibility",
|
|
187
|
+
status: 400,
|
|
188
|
+
message: `visibility must be "private" or "shared" (got: ${JSON.stringify(args.visibility)}). Omit it to use the durability-keyed default: permanent/persistent -> shared, standard/ephemeral -> private.`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
body.visibility = args.visibility;
|
|
192
|
+
}
|
|
162
193
|
return unwrap(await h.post(body));
|
|
163
194
|
}
|
|
164
195
|
/**
|
|
@@ -407,6 +438,15 @@ export const TOOLS = {
|
|
|
407
438
|
type: { type: "string", enum: ["session", "lesson", "decision", "preference", "fact", "goal"], description: "Memory type (default session)" },
|
|
408
439
|
durability: { type: "string", enum: ["permanent", "persistent", "standard", "ephemeral"], description: "permanent > persistent > standard > ephemeral (default standard)" },
|
|
409
440
|
tags: { type: "array", items: { type: "string" }, description: "Tag strings" },
|
|
441
|
+
visibility: {
|
|
442
|
+
type: "string",
|
|
443
|
+
enum: ["private", "shared"],
|
|
444
|
+
description: "Writer-controlled sharing intent. Omit to use the server's durability-keyed default: " +
|
|
445
|
+
"permanent/persistent -> shared, standard/ephemeral -> private. " +
|
|
446
|
+
"private — owner-only, never visible to another agent, even one holding a memory grant. " +
|
|
447
|
+
"shared — visible to the owner and every other agent on this instance. " +
|
|
448
|
+
"The visibility the write actually landed on is returned in the result.",
|
|
449
|
+
},
|
|
410
450
|
usedMemoryIds: { type: "array", items: { type: "string" }, description: "IDs of memories that informed this write (citation-on-write). Credited via the same deduped usage ledger as record_usage. Optional." },
|
|
411
451
|
},
|
|
412
452
|
required: ["content"],
|