Fixes for remaining issues

This commit is contained in:
Bob Dickinson 2026-07-02 18:36:44 -07:00
commit a066873e37
12 changed files with 415 additions and 43 deletions

View file

@ -559,16 +559,23 @@ function App({
);
// Connect — on 401 or mid-session auth recovery, run OAuth then retry.
type TuiOAuthRunResult =
| "success"
| "already_authorized"
| "insufficient_scope"
| "skipped"
| "unsupported";
const runOAuthAuthentication = useCallback(
async (options?: {
challenge?: AuthChallenge;
authorizationUrl?: URL;
/** When set, run OAuth for this server (may differ from the selected server). */
serverName?: string;
}) => {
}): Promise<TuiOAuthRunResult> => {
const serverName = options?.serverName ?? selectedServer;
if (!serverName) {
return;
return "unsupported";
}
const client = inspectorClientsRef.current[serverName];
const serverEntry = mcpServersRef.current[serverName];
@ -578,9 +585,11 @@ function App({
!serverConfig ||
!isOAuthCapableServerConfig(serverConfig)
) {
return;
return "unsupported";
}
if (oauthInProgressRef.current) {
return "skipped";
}
if (oauthInProgressRef.current) return;
oauthInProgressRef.current = true;
getTuiLogger().info(
{ server: serverName },
@ -594,7 +603,7 @@ function App({
const redirectUrlProvider = redirectUrlProvidersRef.current[serverName];
if (!redirectUrlProvider) {
oauthInProgressRef.current = false;
return;
return "unsupported";
}
try {
const result = await runRunnerInteractiveOAuth({
@ -612,11 +621,13 @@ function App({
if (result.kind === "insufficient_scope") {
setOauthStatus("error");
setOauthMessage(stepUpInsufficientScopeMessage(result.challenge));
return;
return "insufficient_scope";
}
if (result.kind === "success" || result.kind === "already_authorized") {
setOauthRevision((n) => n + 1);
return result.kind;
}
return "unsupported";
} finally {
oauthInProgressRef.current = false;
callbackServerRef.current = null;
@ -710,13 +721,21 @@ function App({
}
setOauthStatus("authenticating");
try {
await runOAuthAuthentication({
const oauthResult = await runOAuthAuthentication({
challenge: error.authChallenge,
authorizationUrl: error.authorizationUrl,
serverName,
});
setOauthStatus("idle");
setOauthMessage("Authorization updated. Retry your action.");
if (
oauthResult === "success" ||
oauthResult === "already_authorized"
) {
setOauthStatus("idle");
setOauthMessage("Authorization updated. Retry your action.");
} else if (oauthResult === "skipped") {
setOauthStatus("idle");
setOauthMessage("OAuth already in progress.");
}
} catch (authErr) {
const authMsg =
authErr instanceof Error ? authErr.message : String(authErr);
@ -770,8 +789,16 @@ function App({
setOauthStatus("authenticating");
setOauthMessage(null);
await disconnectInspector();
await runOAuthAuthentication();
await finishConnect();
const oauthResult = await runOAuthAuthentication();
if (
oauthResult === "success" ||
oauthResult === "already_authorized"
) {
await finishConnect();
} else if (oauthResult === "skipped") {
setOauthStatus("idle");
setOauthMessage("OAuth already in progress.");
}
} catch (authErr) {
if (authErr instanceof AuthRecoveryRequiredError) {
handleAuthRecoveryRequired(selectedServer, authErr);
@ -1299,8 +1326,15 @@ function App({
);
if (tabAccelerators[input.toLowerCase()]) {
const nextTab = tabAccelerators[input.toLowerCase()]!;
setActiveTab(nextTab);
setFocus(nextTab === "auth" ? "tabContentList" : "tabs");
const authStepUpAccelerator =
input.toLowerCase() === "a" &&
nextTab === "auth" &&
activeTab === "auth" &&
pendingStepUp?.serverName === selectedServer;
if (!authStepUpAccelerator) {
setActiveTab(nextTab);
setFocus(nextTab === "auth" ? "tabContentList" : "tabs");
}
} else if (key.tab && !key.shift) {
// Flat focus order: servers -> tabs -> list -> details -> wrap to servers
const focusOrder: FocusArea[] =
@ -1689,14 +1723,22 @@ function App({
return;
}
if (outcome.kind === "interactive") {
await runOAuthAuthentication({
const oauthResult = await runOAuthAuthentication({
challenge: outcome.challenge,
authorizationUrl: outcome.authorizationUrl,
});
setOauthStatus("idle");
setOauthMessage(
"Step-up authorization succeeded. Retry your action.",
);
if (
oauthResult === "success" ||
oauthResult === "already_authorized"
) {
setOauthStatus("idle");
setOauthMessage(
"Step-up authorization succeeded. Retry your action.",
);
} else if (oauthResult === "skipped") {
setOauthStatus("idle");
setOauthMessage("OAuth already in progress.");
}
return;
}
if (outcome.kind === "failed") {
@ -1712,14 +1754,22 @@ function App({
);
return;
}
await runOAuthAuthentication({
const oauthResult = await runOAuthAuthentication({
challenge,
authorizationUrl,
});
setOauthStatus("idle");
setOauthMessage(
"Step-up authorization succeeded. Retry your action.",
);
if (
oauthResult === "success" ||
oauthResult === "already_authorized"
) {
setOauthStatus("idle");
setOauthMessage(
"Step-up authorization succeeded. Retry your action.",
);
} else if (oauthResult === "skipped") {
setOauthStatus("idle");
setOauthMessage("OAuth already in progress.");
}
} catch (authErr) {
const authMsg =
authErr instanceof Error

View file

@ -2,6 +2,8 @@ import { describe, it, expect } from "vitest";
import {
computeScopeUnion,
isStrictScopeSuperset,
resolveEffectiveGrantedScope,
resolvePersistedScopeAfterGrant,
} from "@inspector/core/auth/scopes.js";
describe("scopes", () => {
@ -34,4 +36,32 @@ describe("scopes", () => {
expect(isStrictScopeSuperset("mcp weather:read", undefined)).toBe(true);
});
});
describe("resolvePersistedScopeAfterGrant", () => {
it("prefers explicit granted scope over requested", () => {
expect(resolvePersistedScopeAfterGrant("mcp", "mcp weather:read")).toBe(
"mcp",
);
});
it("falls back to requested scope when grant omits scope", () => {
expect(
resolvePersistedScopeAfterGrant(undefined, "mcp weather:read"),
).toBe("mcp weather:read");
});
});
describe("resolveEffectiveGrantedScope", () => {
it("uses token scope when present even if storage overstates grant", () => {
expect(resolveEffectiveGrantedScope("mcp weather:read", "mcp")).toBe(
"mcp",
);
});
it("falls back to stored scope when token omits scope", () => {
expect(resolveEffectiveGrantedScope("mcp tools:read", undefined)).toBe(
"mcp tools:read",
);
});
});
});

View file

@ -0,0 +1,50 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { AuthChallengeError } from "@inspector/core/auth/challenge.js";
import { InspectorClient } from "@inspector/core/mcp/inspectorClient.js";
describe("InspectorClient direct auth recovery retry bound", () => {
beforeEach(() => {
vi.restoreAllMocks();
});
it("retries an operation once after satisfied recovery, then gives up", async () => {
const client = Object.create(InspectorClient.prototype) as InspectorClient;
const internals = client as unknown as {
directAuthRecovery: boolean;
directAuthRecoveryActive: boolean;
reconnectAfterAuthRecovery: ReturnType<typeof vi.fn>;
withDirectAuthRecovery: <T>(
operation: () => Promise<T>,
context?: { method?: string; toolName?: string },
attempt?: number,
) => Promise<T>;
};
internals.directAuthRecovery = true;
internals.directAuthRecoveryActive = true;
internals.reconnectAfterAuthRecovery = vi.fn().mockResolvedValue(undefined);
const challenge = {
reason: "insufficient_scope" as const,
requiredScopes: ["weather:read"],
};
const operation = vi
.fn()
.mockRejectedValue(new AuthChallengeError(challenge, 403));
vi.spyOn(client, "handleAuthChallenge").mockResolvedValue({
kind: "satisfied",
});
vi.spyOn(client, "dispatchTypedEvent").mockImplementation(() => {});
await expect(
internals.withDirectAuthRecovery.call(client, operation, {
method: "tools/call",
toolName: "get_temp",
}),
).rejects.toBeInstanceOf(AuthChallengeError);
expect(operation).toHaveBeenCalledTimes(2);
expect(client.handleAuthChallenge).toHaveBeenCalledTimes(1);
expect(internals.reconnectAfterAuthRecovery).toHaveBeenCalledTimes(1);
});
});

View file

@ -606,6 +606,54 @@ describe("OAuthManager", () => {
);
captureSpy.mockRestore();
});
it("persists granted scope when AS down-scopes the token response", async () => {
mockedMcpAuth.mockResolvedValue("AUTHORIZED");
const params = createMockParams();
storageOf(params).getScope.mockReturnValue("mcp");
storageOf(params).getTokens.mockResolvedValue({
access_token: "access",
token_type: "Bearer",
scope: "mcp",
});
storageOf(params).getClientInformation.mockResolvedValue({
client_id: "cid",
});
const manager = new OAuthManager(params);
(
manager as unknown as { pendingAuthorizationScope: string | undefined }
).pendingAuthorizationScope = "mcp weather:read";
await manager.completeOAuthFlow("code");
expect(storageOf(params).saveScope).toHaveBeenCalledWith(
SERVER_URL,
"mcp",
);
});
it("persists requested scope when the token response omits scope", async () => {
mockedMcpAuth.mockResolvedValue("AUTHORIZED");
const params = createMockParams();
storageOf(params).getTokens.mockResolvedValue({
access_token: "access",
token_type: "Bearer",
});
storageOf(params).getClientInformation.mockResolvedValue({
client_id: "cid",
});
const manager = new OAuthManager(params);
(
manager as unknown as { pendingAuthorizationScope: string | undefined }
).pendingAuthorizationScope = "mcp weather:read";
await manager.completeOAuthFlow("code");
expect(storageOf(params).saveScope).toHaveBeenCalledWith(
SERVER_URL,
"mcp weather:read",
);
});
});
describe("completeOAuthFlow (EMA)", () => {
@ -823,6 +871,34 @@ describe("OAuthManager", () => {
).toBe(true);
});
it("returns false for invalid_token even when a locally valid token exists", async () => {
const params = createMockParams();
storageOf(params).getTokens.mockResolvedValue({
access_token: "tok",
token_type: "Bearer",
expires_in: 3600,
});
const manager = new OAuthManager(params);
expect(
await manager.checkAuthChallengeSatisfied({ reason: "invalid_token" }),
).toBe(false);
});
it("returns false for unauthorized even when a locally valid token exists", async () => {
const params = createMockParams();
storageOf(params).getTokens.mockResolvedValue({
access_token: "tok",
token_type: "Bearer",
expires_in: 3600,
});
const manager = new OAuthManager(params);
expect(
await manager.checkAuthChallengeSatisfied({ reason: "unauthorized" }),
).toBe(false);
});
it("returns true when stored scope covers step-up union", async () => {
const params = createMockParams();
storageOf(params).getTokens.mockResolvedValue({
@ -859,6 +935,24 @@ describe("OAuthManager", () => {
).toBe(false);
});
it("ignores inflated stored scope when token scope is explicit", async () => {
const params = createMockParams();
storageOf(params).getTokens.mockResolvedValue({
access_token: "tok",
token_type: "Bearer",
scope: "mcp",
});
storageOf(params).getScope.mockReturnValue("mcp weather:read");
const manager = new OAuthManager(params);
expect(
await manager.checkAuthChallengeSatisfied({
reason: "insufficient_scope",
requiredScopes: ["weather:read"],
}),
).toBe(false);
});
it("returns false for insufficient_scope with no scopes in the challenge", async () => {
const params = createMockParams();
storageOf(params).getTokens.mockResolvedValue({

View file

@ -121,6 +121,50 @@ describe("test server scope requirements", () => {
expect(result.status).toBe(200);
});
it("enforces requiredScopes on resource templates for resources/read", () => {
const registry = buildScopeRequirementRegistry({
...createMinimalConfig({ tools: [] }),
resourceTemplates: [
{
name: "files",
uriTemplate: "file:///{path}",
requiredScopes: ["files:read"],
handler: async () => ({
contents: [{ uri: "file:///tmp/x", text: "ok" }],
}),
},
],
});
expect(registry.resourceTemplates.get("file:///{path}")).toEqual([
"files:read",
]);
const denied = invokeScopeMiddleware(
registry,
{
jsonrpc: "2.0",
id: 2,
method: "resources/read",
params: { uri: "file:///tmp/example.txt" },
},
mintTestAccessToken("mcp"),
);
expect(denied.next).toBe(false);
expect(denied.status).toBe(403);
const allowed = invokeScopeMiddleware(
registry,
{
jsonrpc: "2.0",
id: 3,
method: "resources/read",
params: { uri: "file:///tmp/example.txt" },
},
mintTestAccessToken("mcp files:read"),
);
expect(allowed.next).toBe(true);
});
it("uses oauthTokenScopes attached by bearer middleware (external JWT path)", () => {
const registry = buildScopeRequirementRegistry({
...createMinimalConfig(),

View file

@ -267,6 +267,18 @@ describe("RemoteSession", () => {
vi.useRealTimers();
});
it("cancelRequestWait clears the timeout for a pending wait", async () => {
vi.useFakeTimers();
const session = new RemoteSession("s-cancel");
const wait = session.waitForRequestResponse(42, 5000);
const rejection = expect(wait).rejects.toThrow(/cancelled/);
session.cancelRequestWait(42);
await rejection;
expect(vi.getTimerCount()).toBe(0);
await vi.advanceTimersByTimeAsync(5000);
vi.useRealTimers();
});
it("setAuthState updates the session auth provider", async () => {
const { createRemoteAuthProvider } =
await import("@inspector/core/mcp/remote/node/tokenAuthProvider.js");

View file

@ -41,3 +41,35 @@ export function isStrictScopeSuperset(
}
return false;
}
/**
* Scope to persist after a successful token grant (RFC 6749 §5.1).
* When the AS returns `scope`, it is the authoritative full grant.
* When `scope` is omitted on success, granted equals what was requested.
*/
export function resolvePersistedScopeAfterGrant(
grantedScope: string | undefined,
requestedScope: string | undefined,
): string | undefined {
const granted = grantedScope?.trim();
if (granted) {
return granted;
}
const requested = requestedScope?.trim();
return requested || undefined;
}
/**
* Scope coverage for satisfaction checks: prefer the token's explicit grant;
* when omitted, fall back to stored scope (RFC implied grant on prior success).
*/
export function resolveEffectiveGrantedScope(
storedScope: string | undefined,
tokenScope: string | undefined,
): string | undefined {
const granted = tokenScope?.trim();
if (granted) {
return granted;
}
return computeScopeUnion(storedScope, tokenScope);
}

View file

@ -43,6 +43,8 @@ import {
import {
computeScopeUnion,
isStrictScopeSuperset,
resolveEffectiveGrantedScope,
resolvePersistedScopeAfterGrant,
} from "../auth/scopes.js";
import { stepUpInsufficientScopeMessage } from "../auth/oauthUx.js";
import type {
@ -276,10 +278,15 @@ export class OAuthManager {
config,
authorizationCode,
);
if (this.pendingAuthorizationScope) {
const requestedScope = this.pendingAuthorizationScope;
const scopeToPersist = resolvePersistedScopeAfterGrant(
tokens.scope,
requestedScope,
);
if (scopeToPersist) {
await this.oauthConfig.storage!.saveScope(
this.getServerUrl(),
this.pendingAuthorizationScope,
scopeToPersist,
);
}
this.pendingAuthorizationScope = undefined;
@ -315,8 +322,11 @@ export class OAuthManager {
throw new Error("Failed to retrieve tokens after authorization");
}
const scopeToPersist =
this.pendingAuthorizationScope ?? tokens.scope;
const requestedScope = this.pendingAuthorizationScope;
const scopeToPersist = resolvePersistedScopeAfterGrant(
tokens.scope,
requestedScope,
);
if (scopeToPersist) {
await provider.saveScope(scopeToPersist);
}
@ -431,8 +441,9 @@ export class OAuthManager {
* satisfied without an authorization-server round-trip.
*
* Returns `true` for `insufficient_scope` when stored + token scope cover the
* SEP-2350 union. For `token_expired` / `unauthorized` / `invalid_token`,
* returns `true` when a usable access token is already in storage.
* SEP-2350 union. For `token_expired`, returns `true` when a usable access
* token is already in storage. `invalid_token` and `unauthorized` always
* return `false` — the resource server explicitly rejected the credential.
*/
async checkAuthChallengeSatisfied(
challenge: AuthChallenge,
@ -450,10 +461,8 @@ export class OAuthManager {
if (challenge.reason !== "insufficient_scope") {
return (
challenge.reason === "token_expired" ||
challenge.reason === "unauthorized" ||
challenge.reason === "invalid_token"
) && isAccessTokenUsable(tokens);
challenge.reason === "token_expired" && isAccessTokenUsable(tokens)
);
}
const enriched = await this.enrichChallengeWithAuthorizationScopes(
@ -467,7 +476,7 @@ export class OAuthManager {
return false;
}
const effectiveScope = computeScopeUnion(
const effectiveScope = resolveEffectiveGrantedScope(
storage.getScope(serverUrl),
tokens.scope,
);
@ -603,10 +612,17 @@ export class OAuthManager {
const silent = await trySilentEmaAuth(config);
if (silent.status === "success") {
if (await this.checkAuthChallengeSatisfied(enriched)) {
if (enriched.authorizationScopes?.length) {
const minted = await this.oauthConfig.storage!.getTokens(
this.getServerUrl(),
);
const scopeToPersist = resolvePersistedScopeAfterGrant(
minted?.scope,
enriched.authorizationScopes?.join(" "),
);
if (scopeToPersist) {
await this.oauthConfig.storage!.saveScope(
this.getServerUrl(),
enriched.authorizationScopes.join(" "),
scopeToPersist,
);
}
return { kind: "satisfied" };
@ -688,8 +704,13 @@ export class OAuthManager {
if (result === "AUTHORIZED") {
if (enriched.reason === "insufficient_scope") {
if (await this.checkAuthChallengeSatisfied(enriched)) {
if (scopeForAuth) {
await provider.saveScope(scopeForAuth);
const freshTokens = await provider.tokens();
const scopeToPersist = resolvePersistedScopeAfterGrant(
freshTokens?.scope,
scopeForAuth,
);
if (scopeToPersist) {
await provider.saveScope(scopeToPersist);
}
return { kind: "satisfied" };
}
@ -799,8 +820,13 @@ export class OAuthManager {
return null;
}
if (await this.checkAuthChallengeSatisfied(enriched)) {
if (scopeForAuth) {
await provider.saveScope(scopeForAuth);
const freshTokens = await provider.tokens();
const scopeToPersist = resolvePersistedScopeAfterGrant(
freshTokens?.scope,
scopeForAuth,
);
if (scopeToPersist) {
await provider.saveScope(scopeToPersist);
}
return { kind: "satisfied" };
}

View file

@ -173,7 +173,12 @@ export class RemoteSession {
}
cancelRequestWait(requestId: string | number): void {
const wait = this.requestWaits.get(requestId);
if (!wait) {
return;
}
this.requestWaits.delete(requestId);
wait.reject(new Error("MCP request wait cancelled"));
}
rejectActiveRequestWaits(error: Error): void {

View file

@ -578,7 +578,12 @@ export class RemoteClientTransport implements Transport {
}
private cancelSseResponseWait(requestId: string | number): void {
const wait = this.sseResponseWaits.get(requestId);
if (!wait) {
return;
}
this.sseResponseWaits.delete(requestId);
wait.reject(new Error("SSE response wait cancelled"));
}
private cancelAllSseWaits(error: Error): void {

View file

@ -54,7 +54,9 @@ By contrast, **401** means the token is missing, invalid, or expired — fix by
| **403** step-up | **Union** previously requested scopes with scopes from the challenge — do not drop scopes needed for other tools |
| **401** re-login | **Replace** scope set (user may down-scope at the AS) |
Inspector persists the previously requested set in `OAuthStorage.scope` (`saveScope()`), computes `authorizationScopes` as the union in `handleAuthChallenge()`, and only persists the union after a **successful** `completeOAuthFlow()`.
Inspector persists granted scopes in `OAuthStorage.scope` (`saveScope()`): when the authorization server returns a `scope` parameter on the token response, that value is **authoritative** (RFC 6749 §5.1 — including when the grant is a subset of what was requested). When `scope` is **omitted** on a successful response, granted scope is assumed to equal what was requested for that exchange. Storage must never claim scopes the access token was not granted.
For step-up, `handleAuthChallenge()` computes `authorizationScopes` as the union of stored grant + challenge scopes for the **authorization request**; `saveScope()` runs only after a successful grant, using the rules above — not the pre-redirect requested union when the AS returned an explicit smaller `scope`.
**UX consequence:** standard-OAuth and **web EMA** step-up need **user-visible consent** before proceeding (web modal, TUI Auth tab confirm, CLI **y/N**). On the web client, EMA `insufficient_scope` shows the same **`StepUpAuthModal`** pattern as standard OAuth, with organization/IdP copy; only after **Authorize** does Inspector run silent re-mint or start an IdP redirect. TUI/CLI may still re-mint silently after their own confirm prompt — see [EMA step-up (web)](v2_auth_ema.md#ema-step-up-web-confirmation).
@ -214,7 +216,7 @@ Read-only check against **current storage** (and token expiry helpers). Used bef
| `token_expired`, `invalid_token`, `unauthorized` | Refresh via `refresh_token` when supported | Authorization code flow (`authenticate()`) |
| `insufficient_scope` | N/A | Authorize with **`authorizationScopes`** = union(previous, challenge) via `mcpAuth({ forceReauthorization: true })`; navigation **deferred** until UI confirms (web modal / TUI Auth / CLI prompt) |
Union scope is held in `pendingAuthorizationScope` until `completeOAuthFlow()` succeeds; cleared on failure.
Union scope is held in `pendingAuthorizationScope` until `completeOAuthFlow()` succeeds; cleared on failure. On success, `saveScope()` stores the AS-granted scope (explicit `scope` on the token response, or the requested union when `scope` is omitted per RFC 6749 §5.1).
#### EMA

View file

@ -837,6 +837,13 @@ export interface ScopeRequirementRegistry {
tools: Map<string, string[]>;
resources: Map<string, string[]>;
prompts: Map<string, string[]>;
resourceTemplates: Map<string, string[]>;
}
function resourceUriMatchesTemplate(uri: string, uriTemplate: string): boolean {
const brace = uriTemplate.indexOf("{");
const prefix = brace >= 0 ? uriTemplate.slice(0, brace) : uriTemplate;
return uri.startsWith(prefix);
}
/** Build lookup tables from merged ServerConfig capability definitions. */
@ -847,6 +854,7 @@ export function buildScopeRequirementRegistry(
tools: new Map(),
resources: new Map(),
prompts: new Map(),
resourceTemplates: new Map(),
};
for (const tool of config.tools ?? []) {
@ -864,6 +872,11 @@ export function buildScopeRequirementRegistry(
registry.prompts.set(prompt.name, prompt.requiredScopes);
}
}
for (const template of config.resourceTemplates ?? []) {
if (template.requiredScopes?.length) {
registry.resourceTemplates.set(template.uriTemplate, template.requiredScopes);
}
}
return registry;
}
@ -874,7 +887,8 @@ export function scopeRequirementRegistryHasEntries(
return (
registry.tools.size > 0 ||
registry.resources.size > 0 ||
registry.prompts.size > 0
registry.prompts.size > 0 ||
registry.resourceTemplates.size > 0
);
}
@ -937,6 +951,14 @@ export function createScopeCheckMiddleware(
requiredScopes = registry.tools.get(target);
} else if (method === "resources/read") {
requiredScopes = registry.resources.get(target);
if (!requiredScopes?.length) {
for (const [uriTemplate, scopes] of registry.resourceTemplates) {
if (resourceUriMatchesTemplate(target, uriTemplate)) {
requiredScopes = scopes;
break;
}
}
}
} else if (method === "prompts/get") {
requiredScopes = registry.prompts.get(target);
}