Some checks failed
Run install, format, lint, build, and test on every push / build (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish (push) Has been cancelled
Run install, format, lint, build, and test on every push / publish-github-container-registry (push) Has been cancelled
* Thread cacheMode through managed list refresh via SDK cache-aware verbs (#1721) Manual/auto refresh of tools/resources/prompts now forces a cache-bypassing round trip on modern servers, delivering the raw-request cacheMode control deferred from #1628. The InspectorClient list/read path uses the SDK's single-page `client.request` verbs (for pagination debugging), which never consult the SDK response cache — so `cacheMode` was inert there. This adds a separate aggregate path that routes through the SDK's cache-aware high-level verbs, where `cacheMode` is honored: - InspectorClient: new `listAllTools/Prompts/Resources/ResourceTemplates( { cacheMode, metadata })` that call `client.listTools()` etc. (all-page aggregate + SEP-2243 x-mcp-header exclusion + cache consult/write). A shared `getCacheableRequestOptions` / `aggregateListParams` back all four. - InspectorClientProtocol: the four `listAll*` methods added. - ManagedListState: delegates page walking to the SDK verb (config `fetchPage` loop → `fetchAll`); `refresh(metadata?, cacheMode?)` threads cacheMode. A `list_changed` auto-refresh bypasses the cache (`cacheMode: "refresh"`). - Managed hooks: user-initiated `refresh()` passes `cacheMode: "refresh"`, so the existing per-tab Refresh buttons become genuinely cache-bypassing — no App wiring change. Connect-time load stays default (`'use'`). The single-page `listTools(cursor)` primitive is unchanged; the paged hooks still surface per-page `nextCursor`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Single-page list pagination: sidebar toggle + server setting (#1721) Delivers the deferred single-page fetch UI: Tools/Resources/Prompts can be loaded one page at a time instead of auto-aggregating every page — a defensive option for servers with very large lists. - New `singlePageLists` server setting (persisted, server-wide), plumbed through ServerConfig/InspectorServerSettings, serverList convert/default, the node + remote settings paths, and the Server Settings form ("Fetch Lists One Page at a Time"). - New `ListPaginationControls` element (a "Single page" switch + "Load next page" button + "N pages loaded" status) rendered in each list sidebar; the toggle edits the setting live (optimistic) and persists it. - The paged state stores (PagedTools/Prompts/ResourcesState) now own loading: they auto-load page 1 on connect in single-page mode and track nextCursor + page count as observable state; the managed stores skip their all-page walk in single-page mode (so nothing is over-fetched) while still lighting the list-changed indicator. `usePaginatedList` selects the display source and pagination controls per mode — pure, no data-loading in React effects. - Refresh is mode-aware: reload page 1 in single-page mode, refetch the whole aggregate otherwise. Sidebars widened 340→360 so the controls don't crowd list entries. - Test server: `test-servers/configs/pagination-http.json` (12 tools/resources/ prompts, page size 4 → 3 pages each) exercises the flow end to end. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Add pr-screenshots proving single-page pagination (#1721) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Address @claude review: template load, double-click guard, auth recovery (#1721) - Fix: resource templates never loaded on connect in single-page mode. The connect-time all-page skip now applies only to lists with a paged counterpart (new `deferInSinglePage` flag on ManagedListConfig; false for resource templates), so templates still aggregate on connect regardless of the setting. - Guard `PagedTools/Prompts/ResourcesState.loadPage` against a concurrent load (fast double-click on "Load next page") so the same page can't be appended twice; the in-flight load owns the cursor and the second call is a no-op. - Thread ambient auth recovery through the single-page refresh, Load-next-page, and mode-switch loads (they previously bypassed it) — usePaginatedList's onRefresh/onLoadMore now return their promises so App can wrap them. - Drop the misleading "aggregated list" wording from the paged-store docs. Tests added for each; npm run coverage green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Review round 2: revert optimistic toggle when persisting the setting fails (#1721) Non-blocking review follow-up. If updateServerSettings rejects, the optimistic `singlePageListsOverride` is now cleared and the live client setting rolled back (the effect only clears the override when the persisted value changes, which doesn't happen on failure), so the UI and client reflect what's on disk rather than the failed edit. Added an App test for the revert path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Fix single-page Refresh not clearing list-changed indicator + UI polish (#1721) Round-3 review fix (blocking): in single-page mode a `list_changed` lights the list-changed indicator, but the single-page Refresh reloads page 1 of the paged store and bypassed `useManaged*.refresh()`, so the indicator was never cleared. Expose `clearListChanged` from useManagedTools/Prompts/Resources and call it in the single-page branch of onRefreshTools/Prompts/Resources. Tests added. UI polish (user feedback on the sidebar control): - Rename the switch label "Single page" → "Paginated". - Put "Load next page" on the same row as the switch, space-between (switch fully left, button fully right). - Move the "N pages loaded" status to its own line below the row, centered. - Hide the whole control once the list is known to be a single page (single-page mode, page 1 loaded, no nextCursor) — nothing to paginate. pr-screenshots refreshed to the new layout. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Rename the singlePageLists setting to paginatedLists for clarity (#1721) `singlePageLists` read backwards from the "Paginated" switch label (it sounds like paginated=false). Rename the server setting to `paginatedLists` with the same value meaning — true = paginated/one-page-at-a-time, false = aggregate-all — and follow through on the related identifiers for consistency: - Setting field `paginatedLists` across ServerConfig / InspectorServerSettings, serverList convert/default, node + remote settings paths, and the form/modal callbacks (onPaginatedListsChange). - `usePaginatedList` / `ListPaginationControls` prop `singlePage` → `paginated`, `onSinglePageChange` → `onPaginatedChange`. - `ManagedListConfig.deferInSinglePage` → `deferWhenPaginated`. - App state (paginatedListsOverride, onTogglePaginatedLists, …) and all tests/fixtures + mode-referring comments. "single-page" wording that denotes the SDK's one-page `client.request` verb (inspectorClient) is intentionally left as-is. Purely a rename — no behavior change; unit + integration + cli + tui + coverage all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Format App.test.tsx (prettier) after the paginatedLists rename (#1721) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
280 lines
11 KiB
TypeScript
280 lines
11 KiB
TypeScript
/**
|
|
* FetchRequestLogState: holds the fetch request log, subscribes to the protocol
|
|
* "fetchRequest" event. Protocol emits per-entry; this manager owns the list and
|
|
* emits both `fetchRequest` (single entry) and `fetchRequestsChange` (full list)
|
|
* on append, and `fetchRequestsChange` on clear. Does not clear on connect or
|
|
* disconnect.
|
|
*
|
|
* When `sessionStorage` and `sessionId` are provided, restores fetch requests
|
|
* from storage on creation and listens for the client's `saveSession` event to
|
|
* persist before OAuth redirect.
|
|
*
|
|
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
|
|
* concrete `InspectorClient` since the runtime class is not yet ported.
|
|
*/
|
|
|
|
import { silentLogger, type InspectorLogger } from "../../logging/logger.js";
|
|
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
|
|
import type { FetchRequestEntry } from "../types.js";
|
|
import type {
|
|
InspectorClientStorage,
|
|
InspectorClientSessionState,
|
|
} from "../sessionStorage.js";
|
|
import type { InspectorClientEventMap } from "../inspectorClientEventTarget.js";
|
|
import {
|
|
TypedEventTarget,
|
|
type TypedEventGeneric,
|
|
} from "../typedEventTarget.js";
|
|
|
|
/**
|
|
* Detail for `fetchRequestBodyDropped`: a deferred response body arrived after
|
|
* its log entry had already rotated out (>= `maxFetchRequests` newer requests
|
|
* appended in between), so the body was unrecoverable. Carries the dropped
|
|
* request id and the cap that was in force, so the UI can explain the drop and
|
|
* offer to raise the limit.
|
|
*/
|
|
export interface FetchRequestBodyDropped {
|
|
id: string;
|
|
maxFetchRequests: number;
|
|
}
|
|
|
|
export interface FetchRequestLogStateEventMap {
|
|
fetchRequest: FetchRequestEntry;
|
|
fetchRequestsChange: FetchRequestEntry[];
|
|
fetchRequestBodyDropped: FetchRequestBodyDropped;
|
|
}
|
|
|
|
export interface FetchRequestLogStateOptions {
|
|
/**
|
|
* Maximum number of fetch requests to store (0 = unlimited, not recommended).
|
|
* When exceeded, oldest entries are dropped. Default 1000.
|
|
*/
|
|
maxFetchRequests?: number;
|
|
/**
|
|
* When provided with sessionId, fetch requests are restored from storage on
|
|
* creation and saved when the client dispatches `saveSession` (e.g. before
|
|
* OAuth redirect).
|
|
*/
|
|
sessionStorage?: InspectorClientStorage;
|
|
/** Session ID for load/save; required for sessionStorage to have effect. */
|
|
sessionId?: string;
|
|
/**
|
|
* Logger for diagnostic traces (e.g. a deferred body update arriving after
|
|
* its entry rotated out of the log). Defaults to the silent logger.
|
|
*/
|
|
logger?: InspectorLogger;
|
|
}
|
|
|
|
export class FetchRequestLogState extends TypedEventTarget<FetchRequestLogStateEventMap> {
|
|
private fetchRequests: FetchRequestEntry[] = [];
|
|
private client: InspectorClientProtocol | null = null;
|
|
private unsubscribe: (() => void) | null = null;
|
|
private maxFetchRequests: number;
|
|
private readonly logger: InspectorLogger;
|
|
|
|
constructor(
|
|
client: InspectorClientProtocol,
|
|
options: FetchRequestLogStateOptions = {},
|
|
) {
|
|
super();
|
|
this.maxFetchRequests = options.maxFetchRequests ?? 1000;
|
|
this.logger = options.logger ?? silentLogger;
|
|
this.client = client;
|
|
|
|
const onFetchRequest = (
|
|
event: TypedEventGeneric<InspectorClientEventMap, "fetchRequest">,
|
|
): void => {
|
|
const entry = event.detail;
|
|
if (
|
|
this.maxFetchRequests > 0 &&
|
|
this.fetchRequests.length >= this.maxFetchRequests
|
|
) {
|
|
this.fetchRequests.shift();
|
|
}
|
|
this.fetchRequests.push(entry);
|
|
this.dispatchTypedEvent("fetchRequest", entry);
|
|
this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests());
|
|
};
|
|
this.client.addEventListener("fetchRequest", onFetchRequest);
|
|
|
|
// Body is read asynchronously by fetchTracking and arrives via a
|
|
// separate event; patch the matching entry in place and re-emit so
|
|
// React subscribers re-render with the body filled in.
|
|
const onFetchRequestBodyUpdate = (
|
|
event: TypedEventGeneric<
|
|
InspectorClientEventMap,
|
|
"fetchRequestBodyUpdate"
|
|
>,
|
|
): void => {
|
|
const { id, responseBody } = event.detail;
|
|
const idx = this.fetchRequests.findIndex((e) => e.id === id);
|
|
if (idx === -1) {
|
|
// The deferred body read (fire-and-forget tee'd stream in
|
|
// fetchTracking) found no matching entry. Distinguish the two causes:
|
|
// a *rotation* drop — the entry was evicted because the log is at
|
|
// capacity (>= maxFetchRequests, with a finite cap) — versus a benign
|
|
// straggler whose entry was simply cleared (length below the cap, or
|
|
// an unlimited log where rotation can't happen). Only the rotation
|
|
// case is unexpected data loss worth surfacing; a post-clear straggler
|
|
// is expected and stays silent.
|
|
const rotatedOut =
|
|
this.maxFetchRequests > 0 &&
|
|
this.fetchRequests.length >= this.maxFetchRequests;
|
|
if (rotatedOut) {
|
|
// `warn` (not `debug`) so the trace clears the web remote logger's
|
|
// default `info` level and is observable without opt-in.
|
|
this.logger.warn(
|
|
{ fetchRequestId: id, maxFetchRequests: this.maxFetchRequests },
|
|
"fetchRequestBodyUpdate dropped: entry rotated out before body arrived",
|
|
);
|
|
// Let the UI surface the drop (toast) and offer to raise the cap.
|
|
this.dispatchTypedEvent("fetchRequestBodyDropped", {
|
|
id,
|
|
maxFetchRequests: this.maxFetchRequests,
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
this.fetchRequests[idx] = {
|
|
...this.fetchRequests[idx]!,
|
|
responseBody,
|
|
};
|
|
// Body fill-in re-emits the list event only, not the per-entry
|
|
// `fetchRequest` event (that one fires once, on append). Consumers read
|
|
// the full list (`useFetchRequests`), so they pick up the body on the
|
|
// next list re-render. A future per-entry subscriber wanting incremental
|
|
// body updates would need its own event here.
|
|
this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests());
|
|
};
|
|
this.client.addEventListener(
|
|
"fetchRequestBodyUpdate",
|
|
onFetchRequestBodyUpdate,
|
|
);
|
|
|
|
const sessionStorage = options.sessionStorage;
|
|
const sessionId = options.sessionId;
|
|
|
|
if (sessionStorage) {
|
|
// Backstop persistence on the client's `saveSession` event. For the
|
|
// OAuth full-page-redirect case the web client's `BrowserNavigation`
|
|
// `beforeNavigate` hook is the *primary* flush (it runs synchronously
|
|
// before navigation, so a keepalive request survives the unload); this
|
|
// listener fires from the same event but can lose the race with an
|
|
// already-scheduled navigation. It remains the save path for any
|
|
// non-redirect `saveSession` caller (e.g. a future token-refresh save
|
|
// point) and is harmless when it duplicates the primary flush —
|
|
// last-writer-wins on an identical payload under the same id.
|
|
//
|
|
// SECURITY TRIPWIRE: captured `auth`-category response bodies in this log
|
|
// are stored UNMASKED (masking is a Network-UI display concern only).
|
|
// Today the only `saveSession` trigger is the pre-redirect flush, which
|
|
// runs before the `/token` exchange — so no bearer token is ever
|
|
// persisted. Any NEW `saveSession` trigger added after token exchange
|
|
// (e.g. a periodic snapshot) would write `access_token` /
|
|
// `refresh_token` to disk. Redact response bodies here before persisting
|
|
// if that ever changes.
|
|
const onSaveSession = (
|
|
event: TypedEventGeneric<InspectorClientEventMap, "saveSession">,
|
|
): void => {
|
|
const { sessionId: id } = event.detail;
|
|
const state: InspectorClientSessionState = {
|
|
fetchRequests: this.getFetchRequests(),
|
|
createdAt: Date.now(),
|
|
updatedAt: Date.now(),
|
|
};
|
|
sessionStorage.saveSession(id, state).catch(() => {
|
|
// fire-and-forget; storage may log internally
|
|
});
|
|
};
|
|
this.client.addEventListener("saveSession", onSaveSession);
|
|
this.unsubscribe = () => {
|
|
if (this.client) {
|
|
this.client.removeEventListener("fetchRequest", onFetchRequest);
|
|
this.client.removeEventListener(
|
|
"fetchRequestBodyUpdate",
|
|
onFetchRequestBodyUpdate,
|
|
);
|
|
this.client.removeEventListener("saveSession", onSaveSession);
|
|
}
|
|
this.client = null;
|
|
};
|
|
if (sessionId) {
|
|
sessionStorage
|
|
.loadSession(sessionId)
|
|
.then((state) => {
|
|
if (this.client && state?.fetchRequests?.length) {
|
|
this.hydrateFetchRequests(state.fetchRequests);
|
|
}
|
|
})
|
|
.catch(() => {
|
|
// fire-and-forget; storage may log internally. Matches the
|
|
// saveSession swallow above so a corrupt or unreadable session
|
|
// doesn't surface as an unhandled rejection.
|
|
});
|
|
}
|
|
} else {
|
|
this.unsubscribe = () => {
|
|
if (this.client) {
|
|
this.client.removeEventListener("fetchRequest", onFetchRequest);
|
|
this.client.removeEventListener(
|
|
"fetchRequestBodyUpdate",
|
|
onFetchRequestBodyUpdate,
|
|
);
|
|
}
|
|
this.client = null;
|
|
};
|
|
}
|
|
}
|
|
|
|
// Restore persisted entries (e.g. the pre-redirect OAuth Network log loaded
|
|
// on the `/oauth/callback` page). Merges rather than replaces: the async
|
|
// load races against entries appended live by the resuming connect
|
|
// (`completeOAuthFlow` + transport handshake), so we must not clobber
|
|
// whichever arrived first. Restored entries are older, so they go in front;
|
|
// duplicates (by id) already present from a live append are skipped.
|
|
private hydrateFetchRequests(entries: FetchRequestEntry[]): void {
|
|
if (entries.length === 0) return;
|
|
const existingIds = new Set(this.fetchRequests.map((e) => e.id));
|
|
const restored = entries.filter((e) => !existingIds.has(e.id));
|
|
if (restored.length === 0) return;
|
|
const merged = [...restored, ...this.fetchRequests];
|
|
this.fetchRequests =
|
|
this.maxFetchRequests > 0 ? merged.slice(-this.maxFetchRequests) : merged;
|
|
this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests());
|
|
}
|
|
|
|
getFetchRequests(): FetchRequestEntry[] {
|
|
return [...this.fetchRequests];
|
|
}
|
|
|
|
/**
|
|
* Adjust the retention cap live (e.g. when the user edits the per-server
|
|
* `maxFetchRequests` setting) without reconnecting. Shrinking the cap trims
|
|
* the oldest entries immediately and re-emits so the Network UI updates;
|
|
* growing it (or setting 0 = unlimited) just takes effect for future appends.
|
|
*/
|
|
setMaxFetchRequests(max: number): void {
|
|
if (max === this.maxFetchRequests) return;
|
|
this.maxFetchRequests = max;
|
|
if (max > 0 && this.fetchRequests.length > max) {
|
|
this.fetchRequests = this.fetchRequests.slice(-max);
|
|
this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clear all fetch requests. Dispatches fetchRequestsChange only if the list
|
|
* was non-empty.
|
|
*/
|
|
clearFetchRequests(): void {
|
|
if (this.fetchRequests.length === 0) return;
|
|
this.fetchRequests = [];
|
|
this.dispatchTypedEvent("fetchRequestsChange", []);
|
|
}
|
|
|
|
destroy(): void {
|
|
this.unsubscribe?.();
|
|
this.unsubscribe = null;
|
|
this.fetchRequests = [];
|
|
}
|
|
}
|