@tpsdev-ai/flair 0.33.0 → 0.35.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.
@@ -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
+ }
@@ -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 stream — read text.
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 = typeof request.text === "function" ? await request.text() : request.body;
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: "0.1.0" },
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,120 @@
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
+ * Boot guard (flair#1021): when FLAIR_MCP_OAUTH is on, the @harperfast/oauth
25
+ * component MUST be declared in config.yaml. Without it the authorization
26
+ * server's routes never mount — discovery, authorize, token, JWKS all 404.
27
+ * The /mcp route would still register, but every request fails closed against
28
+ * a non-existent auth server. This guard fails loudly so the operator sees the
29
+ * error at boot instead of a silently broken deployment.
30
+ *
31
+ * The error names the actor (the operator who set the flag), the state
32
+ * (component absent from config.yaml), and the remedy (add the declaration).
33
+ * It does NOT suggest a concrete issuer — the issuer is derived at runtime
34
+ * from FLAIR_MCP_ISSUER / FLAIR_PUBLIC_URL and must not be hardcoded.
35
+ */
36
+ export function assertHarperOAuthComponentDeclared(harperNs) {
37
+ // Harper exposes parsed config via its runtime namespace. We check for the
38
+ // @harperfast/oauth key; absence means the component's routes were never
39
+ // registered.
40
+ const h = harperNs ?? harper;
41
+ const hc = h.app?.config ?? h.config;
42
+ const component = hc?.get?.("@harperfast/oauth") ?? hc?.["@harperfast/oauth"];
43
+ if (!component) {
44
+ throw new Error("FLAIR_MCP_OAUTH is enabled but the @harperfast/oauth component is not declared in config.yaml. " +
45
+ "The authorization server cannot start without it — discovery, authorize, token, and JWKS endpoints will all 404, " +
46
+ "and /mcp will reject every request.\n" +
47
+ "Add this entry to your config.yaml:\n" +
48
+ "\n" +
49
+ ' "@harperfast/oauth":\n' +
50
+ " providers:\n" +
51
+ ' default:\n' +
52
+ ' authorizationEndpoint: "/OAuthAuthorize"\n' +
53
+ ' tokenEndpoint: "/OAuthToken"\n' +
54
+ ' revocationEndpoint: "/OAuthRevoke"\n' +
55
+ ' registrationEndpoint: "/OAuthRegister"\n' +
56
+ ' jwksUri: "/.well-known/jwks.json"\n' +
57
+ ' discoveryEndpoint: "/.well-known/oauth-authorization-server"\n' +
58
+ "\n" +
59
+ "Then set FLAIR_MCP_ISSUER (or FLAIR_PUBLIC_URL) to your instance's public origin " +
60
+ "and add the corresponding mcp.* block to the component config.");
61
+ }
62
+ }
63
+ /**
64
+ * Initial value: no route has been registered yet, which is literally true until
65
+ * `registerMcpOAuthRoute` runs — a reader between module load and registration
66
+ * would get a 404 from `/mcp`, so reporting "not mounted" is accurate rather
67
+ * than merely safe. It also stays correct for an embedder that sets
68
+ * FLAIR_MCP_NO_AUTOSTART and never calls the registration function.
69
+ */
70
+ let routeState = {
71
+ mounted: false,
72
+ status: "Not mounted",
73
+ reason: "MCP route registration has not run.",
74
+ };
75
+ /** Read the mount decision the router recorded. */
76
+ export function mcpRouteState() {
77
+ return routeState;
78
+ }
79
+ /**
80
+ * Record a decision and return its `mounted` value. Every `return` in
81
+ * `registerMcpOAuthRoute` goes through here, so a branch cannot decide the
82
+ * route's fate without publishing that decision.
83
+ */
84
+ function decide(state) {
85
+ routeState = state;
86
+ return state.mounted;
87
+ }
22
88
  async function defaultLoadWithMCPAuth() {
23
89
  // Dynamic import so the dep is only required when the surface is enabled.
24
90
  const mod = (await import("@harperfast/oauth"));
25
91
  return mod.withMCPAuth;
26
92
  }
93
+ /**
94
+ * Wrap the /mcp handler in the per-subject rate limit.
95
+ *
96
+ * Placed INSIDE `withMCPAuth` — i.e. the guard runs first and this runs second —
97
+ * so the key is the RS256-verified `sub` from the token rather than a network
98
+ * address. That is the identity worth limiting on for an authenticated surface:
99
+ * it is the authorization server's own assertion, it survives the caller
100
+ * changing address, and it is exactly the thing "a valid token can hammer the
101
+ * tools" is about. Authentication is not a rate limit.
102
+ *
103
+ * The consequence of that placement, stated rather than left implicit: requests
104
+ * bearing an INVALID token are rejected by `withMCPAuth` before this runs and so
105
+ * are not counted here. Those cost one JWT verification against a locally-cached
106
+ * key and touch no flair table or tool, which is a materially cheaper path than
107
+ * a tool call — but it is not zero, and it is not throttled at this layer.
108
+ *
109
+ * Exported for tests: the limiter's behaviour is asserted directly on this
110
+ * wrapper, without needing the plugin present.
111
+ */
112
+ export function rateLimitedMcpHandler(handler) {
113
+ return async (request) => {
114
+ const limited = checkMcpRateLimit(request);
115
+ if (limited)
116
+ return limited;
117
+ return handler(request);
118
+ };
119
+ }
27
120
  export async function registerMcpOAuthRoute(deps = {}) {
28
- if (!mcpOAuthEnabled())
29
- return false; // OFF → no route, no import, no side effects.
121
+ if (!mcpOAuthEnabled()) {
122
+ // OFF → no route, no import, no side effects.
123
+ return decide({
124
+ mounted: false,
125
+ status: "Not enabled",
126
+ reason: "Set FLAIR_MCP_OAUTH=1 (and an issuer) to serve MCP over HTTP.",
127
+ });
128
+ }
129
+ // Boot guard (flair#1021): fail loudly if the operator enabled the flag but
130
+ // the @harperfast/oauth component is absent from config.yaml. Without it the
131
+ // authorization server's routes never mount — discovery, authorize, token,
132
+ // JWKS all 404 — and the /mcp guard has nothing to validate against.
133
+ if (!deps.skipComponentGuard) {
134
+ assertHarperOAuthComponentDeclared(deps.harper);
135
+ }
30
136
  const config = mcpAuthConfig();
31
137
  if (!config) {
32
138
  // Flag on but issuer unset → we cannot safely pin iss/aud. Do NOT mount an
@@ -34,7 +140,11 @@ export async function registerMcpOAuthRoute(deps = {}) {
34
140
  // is the clearer signal). Log and bail — the operator must set FLAIR_MCP_ISSUER.
35
141
  console.error("[mcp-oauth] FLAIR_MCP_OAUTH is on but no issuer configured " +
36
142
  "(set FLAIR_MCP_ISSUER or FLAIR_PUBLIC_URL) — /mcp NOT mounted.");
37
- return false;
143
+ return decide({
144
+ mounted: false,
145
+ status: "Not mounted",
146
+ reason: "FLAIR_MCP_OAUTH is on but no issuer is configured — set FLAIR_MCP_ISSUER (or FLAIR_PUBLIC_URL).",
147
+ });
38
148
  }
39
149
  let withMCPAuth;
40
150
  try {
@@ -42,11 +152,22 @@ export async function registerMcpOAuthRoute(deps = {}) {
42
152
  }
43
153
  catch (err) {
44
154
  console.error("[mcp-oauth] @harperfast/oauth not available — /mcp NOT mounted: " + (err?.message ?? err));
45
- return false;
155
+ // The underlying error text stays in the log rather than being carried into
156
+ // an operator-facing string: it is arbitrary text from a dependency, and the
157
+ // admin page is HTML.
158
+ return decide({
159
+ mounted: false,
160
+ status: "Not mounted",
161
+ reason: "The @harperfast/oauth plugin could not be loaded — see the server log.",
162
+ });
46
163
  }
47
164
  if (typeof withMCPAuth !== "function") {
48
165
  console.error("[mcp-oauth] @harperfast/oauth has no withMCPAuth export — /mcp NOT mounted.");
49
- return false;
166
+ return decide({
167
+ mounted: false,
168
+ status: "Not mounted",
169
+ reason: "The @harperfast/oauth plugin has no withMCPAuth export — see the server log.",
170
+ });
50
171
  }
51
172
  // Resolve the handler lazily (injected in tests; real module otherwise) — see
52
173
  // the top-of-file note on why it isn't a static import.
@@ -60,11 +181,11 @@ export async function registerMcpOAuthRoute(deps = {}) {
60
181
  // wrapper's iss/aud checks match the minted tokens even if this component
61
182
  // resolves a different node_modules copy of the plugin (docs/mcp-oauth.md
62
183
  // §"Using withMCPAuth from a different component").
63
- srv.http(withMCPAuth(handler, {
184
+ srv.http(withMCPAuth(rateLimitedMcpHandler(handler), {
64
185
  getConfig: () => mcpAuthConfig(),
65
186
  }), { urlPath: "/mcp" });
66
187
  console.error(`[mcp-oauth] /mcp mounted (OAuth-guarded); issuer=${config.issuer}`);
67
- return true;
188
+ return decide({ mounted: true });
68
189
  }
69
190
  // Fire-and-forget at module load. Any failure is contained inside
70
191
  // registerMcpOAuthRoute (it logs and returns) so it can never crash flair boot.
@@ -81,6 +202,14 @@ export async function registerMcpOAuthRoute(deps = {}) {
81
202
  // deployment.)
82
203
  if (process.env.FLAIR_MCP_NO_AUTOSTART == null) {
83
204
  void registerMcpOAuthRoute().catch((err) => {
205
+ // A throw escaping registerMcpOAuthRoute means the mount never happened, so
206
+ // record that too — otherwise mcpRouteState() would keep reporting whatever
207
+ // the last completed decision was.
208
+ decide({
209
+ mounted: false,
210
+ status: "Not mounted",
211
+ reason: "MCP route registration failed — see the server log.",
212
+ });
84
213
  console.error("[mcp-oauth] route registration failed (surface not mounted): " + (err?.message ?? err));
85
214
  });
86
215
  }