// Detecting a scope-insufficient upstream rejection. A 403 that means "this// grant does not cover that operation" is unfixable by re-running the same// OAuth flow, so it must not be labelled connection_rejected (whose recovery// tells the agent to re-authenticate). Providers signal it three ways://// - RFC 6750: a `WWW-Authenticate: Bearer error="insufficient_scope"// scope="..."` challenge header (the `scope` attribute names what the// request needed).// - Google (google.rpc.ErrorInfo): a JSON body whose error details carry// `reason: "ACCESS_TOKEN_SCOPE_INSUFFICIENT"`.// - Generic OAuth JSON: `{ "error": "insufficient_scope" }` (RFC 6750 §3.1// as a body, which some providers emit instead of the header).//// Detection is deliberately strict — a false positive strips the// re-authenticate recovery from a 403 that re-auth WOULD fix://// - Challenge headers are PARSED into parameters (quoted values consumed// whole), so `error=insufficient_scope` inside another parameter's// quoted value never counts; only the `error` parameter's own exact// value does.// - Structured bodies match only exact `error` / `reason` field values.// - Text bodies are JSON-parsed first and then inspected structurally;// non-JSON text never classifies (prose mentioning the tokens misses).//// A miss is benign: the failure stays on the existing classification.export type InsufficientScopeDetection = { /** Scopes the upstream named as required, when it named any (RFC 6750's * `scope` attribute). Empty when the provider only signalled the class of * failure (Google's ErrorInfo does not carry the missing scope). */ readonly requiredScopes: readonly string[];};const MAX_DEPTH = 8;const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);/** Parser for the whole WWW-Authenticate header per RFC 7235 §2.1: a * comma-separated #list of challenges, each * `scheme [ 1*SP ( token68 / #auth-param ) ]`. Implemented as an explicit * per-challenge state machine so params can never attach across challenge * boundaries or to a token68 credential: * * - "scheme": just read a scheme; accepts a token68 OR a first auth-param * (space-separated, no comma). * - "params": accepts further auth-params ONLY after a comma. * - "token68": accepts nothing; any trailing param is malformed. * * Auth-params allow BWS around `=` (RFC 7230). Quoted-strings consume * quoted-pairs whole and must end at a separator. ANY malformed shape — * scheme-less params, space-separated param runs, params after token68, * stray quotes/bytes — returns null and never classifies: a miss is benign, * a false positive strips a valid recovery path. */type Challenge = { readonly scheme: string; readonly params: Map<string, string> };// HTTP `token` alphabet (RFC 7230 §3.2.6) — schemes and auth-param names.const TOKEN_RE = /[A-Za-z0-9!#$%&'*+.^_`|~-]/;// token68 alphabet (RFC 7235 §2.1), padding `=` handled separately.const TOKEN68_RE = /[A-Za-z0-9._~+/-]/;// Superset used by the word reader; each use site validates against the// context-specific alphabet after reading.const WORD_RE = /[A-Za-z0-9!#$%&'*+.^_`|~/-]/;const isToken = (word: string): boolean => [...word].every((ch) => TOKEN_RE.test(ch));// Unquoted URL values some providers emit (scheme://host/path?query): URI// characters per RFC 3986, no whitespace/comma/quotes.const isUrlish = (word: string): boolean => /^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s,"]+$/.test(word);const isToken68 = (word: string): boolean => [...word].every((ch) => TOKEN68_RE.test(ch));const parseChallenges = (header: string): readonly Challenge[] | null => { const len = header.length; const challenges: Challenge[] = []; let current: Challenge | null = null; let state: "boundary" | "scheme" | "token68" | "params" = "boundary"; let sawComma = true; // header start counts as a list boundary let i = 0; const readWord = (): string => { const start = i; while (i < len && WORD_RE.test(header[i]!)) i += 1; return header.slice(start, i); }; // Returns null on an unterminated quote or a quote run into the next token. const readQuoted = (): string | null => { let value = ""; i += 1; // opening quote while (i < len) { const ch = header[i]!; if (ch === '"') { i += 1; return i >= len || /[\s,]/.test(header[i]!) ? value : null; } if (ch === "\\" && i + 1 < len) { value += header[i + 1]; i += 2; continue; } value += ch; i += 1; } return null; // unterminated }; while (i < len) { while (i < len && /\s/.test(header[i]!)) i += 1; if (i >= len) break; if (header[i] === ",") { sawComma = true; i += 1; continue; } if (!WORD_RE.test(header[i]!)) return null; // stray quote/byte: malformed const word = readWord(); // Look ahead through BWS for `=` to classify the word. let j = i; while (j < len && /[ \t]/.test(header[j]!)) j += 1; const isPaddingRun = (() => { // An `=`-run directly on the word (no BWS) that is followed (after // optional whitespace) by a comma or the end of input is token68 // padding. An `=` followed by a value — even across BWS — is an // auth-param (RFC 7230 allows BWS around `=`). if (header[i] !== "=") return false; let k = i; while (k < len && header[k] === "=") k += 1; while (k < len && /[ \t]/.test(header[k]!)) k += 1; return k >= len || header[k] === ","; })(); if (isPaddingRun) { // token68 with padding — only legal directly after a scheme. if (state !== "scheme" || sawComma) return null; if (!isToken68(word)) return null; while (i < len && header[i] === "=") i += 1; state = "token68"; sawComma = false; continue; } if (header[j] === "=") { // auth-param: `word BWS = BWS value`. if (!isToken(word)) return null; // param name must be an HTTP token if (current === null) return null; // scheme-less param if (state === "token68") return null; // params after token68 if (state === "scheme" && sawComma) return null; // "Bearer, a=b" if (state === "params" && !sawComma) return null; // space-separated run i = j + 1; while (i < len && /[ \t]/.test(header[i]!)) i += 1; let value: string; if (header[i] === '"') { const quoted = readQuoted(); if (quoted === null) return null; value = quoted; } else { const start = i; while (i < len && !/[\s,]/.test(header[i]!)) i += 1; value = header.slice(start, i); // An unquoted value must be an HTTP token (`realm =,` / `realm=;` // are malformed) — EXCEPT that real providers emit unquoted URLs for // resource_metadata (observed live: Stripe), so URL-safe characters // are tolerated there. The signal params (`error`, `scope`) stay // token-strict. if (value.length === 0) return null; const lowerName = word.toLowerCase(); if (!isToken(value) && !(lowerName === "resource_metadata" && isUrlish(value))) { return null; } } // Duplicate SIGNAL params (`error`, `scope`) within one challenge mean // a header playing games — never classify. Other duplicates are // tolerated first-wins: real providers emit them (observed live: // Sentry duplicates resource_metadata). const key = word.toLowerCase(); if (current.params.has(key)) { if (key === "error" || key === "scope") return null; } else { current.params.set(key, value); } state = "params"; sawComma = false; continue; } // Bare word: a new challenge's scheme at a list boundary, a token68 // directly after a scheme, malformed anywhere else. if (sawComma) { if (!isToken(word)) return null; // a scheme must be an HTTP token current = { scheme: word.toLowerCase(), params: new Map() }; challenges.push(current); state = "scheme"; sawComma = false; continue; } if (state === "scheme") { if (!isToken68(word)) return null; state = "token68"; continue; } return null; } return challenges;};const detectFromChallenge = (header: string): InsufficientScopeDetection | null => { const challenges = parseChallenges(header); if (challenges === null) return null; for (const challenge of challenges) { if (challenge.scheme !== "bearer") continue; if (challenge.params.get("error") !== "insufficient_scope") continue; const scope = challenge.params.get("scope"); return { requiredScopes: scope ? scope.split(/\s+/).filter(Boolean) : [] }; } return null;};const detectFromStructured = (body: unknown, depth: number): boolean => { if (depth > MAX_DEPTH) return false; if (Array.isArray(body)) { return body.some((item) => detectFromStructured(item, depth + 1)); } if (!isRecord(body)) return false; if (body.error === "insufficient_scope") return true; if (body.reason === "ACCESS_TOKEN_SCOPE_INSUFFICIENT") return true; // Recurse into records/arrays only — nested strings are data (scope lists, // docs prose), never the error envelope itself. return Object.values(body).some( (value) => (isRecord(value) || Array.isArray(value)) && detectFromStructured(value, depth + 1), );};const parseJsonSafe = (text: string): unknown => { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: classifying an untrusted upstream error body; a parse failure just means "not a JSON body", never an error path try { // oxlint-disable-next-line executor/no-json-parse -- boundary: same untrusted-body classification; the parsed value is only structurally inspected, never decoded into domain types return JSON.parse(text) as unknown; } catch { return undefined; }};const detectFromBody = (body: unknown): boolean => { if (typeof body === "string") { const parsed = parseJsonSafe(body); return parsed !== undefined && detectFromStructured(parsed, 0); } return detectFromStructured(body, 0);};/** Strict detection over a text fragment whose JSON body survives only as a * suffix of a transport error message (the MCP SDK embeds the failed POST's * body after a fixed prefix). The JSON is isolated from the first `{` or `[` * and parsed; prose without a parseable JSON envelope never classifies. */export const insufficientScopeFromEmbeddedJson = (text: string): boolean => { const start = text.search(/[{[]/); if (start < 0) return false; const parsed = parseJsonSafe(text.slice(start)); return parsed !== undefined && detectFromStructured(parsed, 0);};/** Inspect an upstream 401/403's body and headers for a scope-insufficiency * signal. Returns `null` when nothing matches, so callers fall through to * their existing classification. */export const detectInsufficientScope = (input: { readonly body?: unknown; readonly headers?: Record<string, string>;}): InsufficientScopeDetection | null => { for (const [name, value] of Object.entries(input.headers ?? {})) { if (name.toLowerCase() !== "www-authenticate") continue; const detected = detectFromChallenge(value); if (detected) return detected; } return detectFromBody(input.body) ? { requiredScopes: [] } : null;};
// Detecting a scope-insufficient upstream rejection. A 403 that means "this
// grant does not cover that operation" is unfixable by re-running the same
// OAuth flow, so it must not be labelled connection_rejected (whose recovery
// tells the agent to re-authenticate). Providers signal it three ways:
//
// - RFC 6750: a `WWW-Authenticate: Bearer error="insufficient_scope"
// scope="..."` challenge header (the `scope` attribute names what the
// request needed).
// - Google (google.rpc.ErrorInfo): a JSON body whose error details carry
// `reason: "ACCESS_TOKEN_SCOPE_INSUFFICIENT"`.
// - Generic OAuth JSON: `{ "error": "insufficient_scope" }` (RFC 6750 §3.1
// as a body, which some providers emit instead of the header).
//
// Detection is deliberately strict — a false positive strips the
// re-authenticate recovery from a 403 that re-auth WOULD fix:
//
// - Challenge headers are PARSED into parameters (quoted values consumed
// whole), so `error=insufficient_scope` inside another parameter's
// quoted value never counts; only the `error` parameter's own exact
// value does.
// - Structured bodies match only exact `error` / `reason` field values.
// - Text bodies are JSON-parsed first and then inspected structurally;
// non-JSON text never classifies (prose mentioning the tokens misses).
//
// A miss is benign: the failure stays on the existing classification.
export type InsufficientScopeDetection = {
/** Scopes the upstream named as required, when it named any (RFC 6750's
* `scope` attribute). Empty when the provider only signalled the class of
* failure (Google's ErrorInfo does not carry the missing scope). */
readonly requiredScopes: readonly string[];
};
const MAX_DEPTH = 8;
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value);
/** Parser for the whole WWW-Authenticate header per RFC 7235 §2.1: a
* comma-separated #list of challenges, each
* `scheme [ 1*SP ( token68 / #auth-param ) ]`. Implemented as an explicit
* per-challenge state machine so params can never attach across challenge
* boundaries or to a token68 credential:
*
* - "scheme": just read a scheme; accepts a token68 OR a first auth-param
* (space-separated, no comma).
* - "params": accepts further auth-params ONLY after a comma.
* - "token68": accepts nothing; any trailing param is malformed.
*
* Auth-params allow BWS around `=` (RFC 7230). Quoted-strings consume
* quoted-pairs whole and must end at a separator. ANY malformed shape —
* scheme-less params, space-separated param runs, params after token68,
* stray quotes/bytes — returns null and never classifies: a miss is benign,
* a false positive strips a valid recovery path. */
type Challenge = { readonly scheme: string; readonly params: Map<string, string> };
// HTTP `token` alphabet (RFC 7230 §3.2.6) — schemes and auth-param names.
const TOKEN_RE = /[A-Za-z0-9!#$%&'*+.^_`|~-]/;
// token68 alphabet (RFC 7235 §2.1), padding `=` handled separately.
const TOKEN68_RE = /[A-Za-z0-9._~+/-]/;
// Superset used by the word reader; each use site validates against the
// context-specific alphabet after reading.
const WORD_RE = /[A-Za-z0-9!#$%&'*+.^_`|~/-]/;
const isToken = (word: string): boolean => [...word].every((ch) => TOKEN_RE.test(ch));
// Unquoted URL values some providers emit (scheme://host/path?query): URI
// characters per RFC 3986, no whitespace/comma/quotes.
const isUrlish = (word: string): boolean => /^[A-Za-z][A-Za-z0-9+.-]*:\/\/[^\s,"]+$/.test(word);
const isToken68 = (word: string): boolean => [...word].every((ch) => TOKEN68_RE.test(ch));
const parseChallenges = (header: string): readonly Challenge[] | null => {
const len = header.length;
const challenges: Challenge[] = [];
let current: Challenge | null = null;
let state: "boundary" | "scheme" | "token68" | "params" = "boundary";
let sawComma = true; // header start counts as a list boundary
let i = 0;
const readWord = (): string => {
const start = i;
while (i < len && WORD_RE.test(header[i]!)) i += 1;
return header.slice(start, i);
};
// Returns null on an unterminated quote or a quote run into the next token.
const readQuoted = (): string | null => {
let value = "";
i += 1; // opening quote
while (i < len) {
const ch = header[i]!;
if (ch === '"') {
i += 1;
return i >= len || /[\s,]/.test(header[i]!) ? value : null;
}
if (ch === "\\" && i + 1 < len) {
value += header[i + 1];
i += 2;
continue;
}
value += ch;
i += 1;
}
return null; // unterminated
};
while (i < len) {
while (i < len && /\s/.test(header[i]!)) i += 1;
if (i >= len) break;
if (header[i] === ",") {
sawComma = true;
i += 1;
continue;
}
if (!WORD_RE.test(header[i]!)) return null; // stray quote/byte: malformed
const word = readWord();
// Look ahead through BWS for `=` to classify the word.
let j = i;
while (j < len && /[ \t]/.test(header[j]!)) j += 1;
const isPaddingRun = (() => {
// An `=`-run directly on the word (no BWS) that is followed (after
// optional whitespace) by a comma or the end of input is token68
// padding. An `=` followed by a value — even across BWS — is an
// auth-param (RFC 7230 allows BWS around `=`).
if (header[i] !== "=") return false;
let k = i;
while (k < len && header[k] === "=") k += 1;
while (k < len && /[ \t]/.test(header[k]!)) k += 1;
return k >= len || header[k] === ",";
})();
if (isPaddingRun) {
// token68 with padding — only legal directly after a scheme.
if (state !== "scheme" || sawComma) return null;
if (!isToken68(word)) return null;
while (i < len && header[i] === "=") i += 1;
state = "token68";
sawComma = false;
continue;
}
if (header[j] === "=") {
// auth-param: `word BWS = BWS value`.
if (!isToken(word)) return null; // param name must be an HTTP token
if (current === null) return null; // scheme-less param
if (state === "token68") return null; // params after token68
if (state === "scheme" && sawComma) return null; // "Bearer, a=b"
if (state === "params" && !sawComma) return null; // space-separated run
i = j + 1;
while (i < len && /[ \t]/.test(header[i]!)) i += 1;
let value: string;
if (header[i] === '"') {
const quoted = readQuoted();
if (quoted === null) return null;
value = quoted;
} else {
const start = i;
while (i < len && !/[\s,]/.test(header[i]!)) i += 1;
value = header.slice(start, i);
// An unquoted value must be an HTTP token (`realm =,` / `realm=;`
// are malformed) — EXCEPT that real providers emit unquoted URLs for
// resource_metadata (observed live: Stripe), so URL-safe characters
// are tolerated there. The signal params (`error`, `scope`) stay
// token-strict.
if (value.length === 0) return null;
const lowerName = word.toLowerCase();
if (!isToken(value) && !(lowerName === "resource_metadata" && isUrlish(value))) {
return null;
}
}
// Duplicate SIGNAL params (`error`, `scope`) within one challenge mean
// a header playing games — never classify. Other duplicates are
// tolerated first-wins: real providers emit them (observed live:
// Sentry duplicates resource_metadata).
const key = word.toLowerCase();
if (current.params.has(key)) {
if (key === "error" || key === "scope") return null;
} else {
current.params.set(key, value);
}
state = "params";
sawComma = false;
continue;
}
// Bare word: a new challenge's scheme at a list boundary, a token68
// directly after a scheme, malformed anywhere else.
if (sawComma) {
if (!isToken(word)) return null; // a scheme must be an HTTP token
current = { scheme: word.toLowerCase(), params: new Map() };
challenges.push(current);
state = "scheme";
sawComma = false;
continue;
}
if (state === "scheme") {
if (!isToken68(word)) return null;
state = "token68";
continue;
}
return null;
}
return challenges;
};
const detectFromChallenge = (header: string): InsufficientScopeDetection | null => {
const challenges = parseChallenges(header);
if (challenges === null) return null;
for (const challenge of challenges) {
if (challenge.scheme !== "bearer") continue;
if (challenge.params.get("error") !== "insufficient_scope") continue;
const scope = challenge.params.get("scope");
return { requiredScopes: scope ? scope.split(/\s+/).filter(Boolean) : [] };
}
return null;
};
const detectFromStructured = (body: unknown, depth: number): boolean => {
if (depth > MAX_DEPTH) return false;
if (Array.isArray(body)) {
return body.some((item) => detectFromStructured(item, depth + 1));
}
if (!isRecord(body)) return false;
if (body.error === "insufficient_scope") return true;
if (body.reason === "ACCESS_TOKEN_SCOPE_INSUFFICIENT") return true;
// Recurse into records/arrays only — nested strings are data (scope lists,
// docs prose), never the error envelope itself.
return Object.values(body).some(
(value) => (isRecord(value) || Array.isArray(value)) && detectFromStructured(value, depth + 1),
);
};
const parseJsonSafe = (text: string): unknown => {
// oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: classifying an untrusted upstream error body; a parse failure just means "not a JSON body", never an error path
try {
// oxlint-disable-next-line executor/no-json-parse -- boundary: same untrusted-body classification; the parsed value is only structurally inspected, never decoded into domain types
return JSON.parse(text) as unknown;
} catch {
return undefined;
}
};
const detectFromBody = (body: unknown): boolean => {
if (typeof body === "string") {
const parsed = parseJsonSafe(body);
return parsed !== undefined && detectFromStructured(parsed, 0);
}
return detectFromStructured(body, 0);
};
/** Strict detection over a text fragment whose JSON body survives only as a
* suffix of a transport error message (the MCP SDK embeds the failed POST's
* body after a fixed prefix). The JSON is isolated from the first `{` or `[`
* and parsed; prose without a parseable JSON envelope never classifies. */
export const insufficientScopeFromEmbeddedJson = (text: string): boolean => {
const start = text.search(/[{[]/);
if (start < 0) return false;
const parsed = parseJsonSafe(text.slice(start));
return parsed !== undefined && detectFromStructured(parsed, 0);
};
/** Inspect an upstream 401/403's body and headers for a scope-insufficiency
* signal. Returns `null` when nothing matches, so callers fall through to
* their existing classification. */
export const detectInsufficientScope = (input: {
readonly body?: unknown;
readonly headers?: Record<string, string>;
}): InsufficientScopeDetection | null => {
for (const [name, value] of Object.entries(input.headers ?? {})) {
if (name.toLowerCase() !== "www-authenticate") continue;
const detected = detectFromChallenge(value);
if (detected) return detected;
}
return detectFromBody(input.body) ? { requiredScopes: [] } : null;
};