Thread cacheMode through managed list refresh via SDK cache-aware verbs (#1721) (#1723)
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
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>
This commit is contained in:
parent
d552564966
commit
b3b04c6939
17 changed files with 2494 additions and 247 deletions
|
|
@ -7,6 +7,7 @@
|
|||
|
||||
import { vi } from "vitest";
|
||||
import type {
|
||||
CacheMode,
|
||||
ClientCapabilities,
|
||||
DiscoverResult,
|
||||
Implementation,
|
||||
|
|
@ -40,6 +41,24 @@ type ListResult<TKey extends string, TItem> = {
|
|||
[K in TKey]: TItem[];
|
||||
} & { nextCursor?: string };
|
||||
|
||||
/**
|
||||
* Drain the entire queue of pre-canned pages for a list key and return the
|
||||
* flattened items — the aggregate `listAll*` mocks consume every queued page in
|
||||
* one call, mirroring the SDK's all-page walk (the single-page `list*` mocks
|
||||
* shift one page per call instead).
|
||||
*/
|
||||
function drainPages<TKey extends string, TItem>(
|
||||
pages: Array<ListResult<TKey, TItem>>,
|
||||
key: TKey,
|
||||
): TItem[] {
|
||||
const items: TItem[] = [];
|
||||
while (pages.length > 0) {
|
||||
const page = pages.shift();
|
||||
if (page) items.push(...page[key]);
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
export interface FakeInspectorClientOptions {
|
||||
status?: ConnectionStatus;
|
||||
capabilities?: ServerCapabilities;
|
||||
|
|
@ -93,6 +112,40 @@ export class FakeInspectorClient
|
|||
async () => this.taskPages.shift() ?? { tasks: [] },
|
||||
);
|
||||
|
||||
// Aggregate variants used by the managed state stores on refresh: drain ALL
|
||||
// queued pages (mimicking the SDK's all-page walk) and return the flattened
|
||||
// list. The `options` (incl. `cacheMode`) is recorded by the `vi.fn` so tests
|
||||
// can assert a user/auto refresh forwarded `cacheMode: "refresh"`.
|
||||
listAllTools = vi.fn(
|
||||
async (_options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
}) => ({ tools: drainPages(this.toolPages, "tools") }),
|
||||
);
|
||||
listAllPrompts = vi.fn(
|
||||
async (_options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
}) => ({ prompts: drainPages(this.promptPages, "prompts") }),
|
||||
);
|
||||
listAllResources = vi.fn(
|
||||
async (_options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
}) => ({ resources: drainPages(this.resourcePages, "resources") }),
|
||||
);
|
||||
listAllResourceTemplates = vi.fn(
|
||||
async (_options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
}) => ({
|
||||
resourceTemplates: drainPages(
|
||||
this.resourceTemplatePages,
|
||||
"resourceTemplates",
|
||||
),
|
||||
}),
|
||||
);
|
||||
|
||||
callTool = vi.fn(
|
||||
async (
|
||||
tool: Tool,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ import type {
|
|||
import type { Transport } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
RequestOptions,
|
||||
CacheableRequestOptions,
|
||||
CacheMode,
|
||||
ProgressCallback,
|
||||
VersionNegotiationOptions,
|
||||
ProtocolEra,
|
||||
|
|
@ -676,6 +678,38 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link getRequestOptions} plus the per-call `cacheMode` for the SDK's
|
||||
* cacheable verbs (the high-level `client.listTools()` / `listPrompts()` /
|
||||
* `listResources()` / `listResourceTemplates()` used by the `listAll*`
|
||||
* aggregate methods below). `cacheMode` is only honored by those wrappers —
|
||||
* the single-page `client.request` path ignores it — so it lives here rather
|
||||
* than in `getRequestOptions`. Omitted when unset so the SDK default
|
||||
* (`'use'`) applies.
|
||||
*/
|
||||
private getCacheableRequestOptions(
|
||||
cacheMode?: CacheMode,
|
||||
): CacheableRequestOptions {
|
||||
const opts: CacheableRequestOptions = this.getRequestOptions();
|
||||
if (cacheMode !== undefined) {
|
||||
opts.cacheMode = cacheMode;
|
||||
}
|
||||
return opts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the `params` for the aggregate `listAll*` verbs: merge call metadata
|
||||
* with `defaultMetadata` and wrap as `{ _meta }`, or `undefined` when empty
|
||||
* (so the SDK skips an empty `_meta`). Shared by all four `listAll*` methods
|
||||
* so the merge/omit branch is defined once.
|
||||
*/
|
||||
private aggregateListParams(
|
||||
metadata?: Record<string, string>,
|
||||
): { _meta: Record<string, string> } | undefined {
|
||||
const effectiveMeta = this.mergeMeta(metadata);
|
||||
return effectiveMeta ? { _meta: effectiveMeta } : undefined;
|
||||
}
|
||||
|
||||
private isHttpOAuthConfig(): boolean {
|
||||
const serverType = getServerTypeFromConfig(this.transportConfig);
|
||||
return (
|
||||
|
|
@ -2098,6 +2132,34 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
return { tools, nextCursor: response.nextCursor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate ALL pages of `tools/list` via the SDK's high-level
|
||||
* `client.listTools()` — the cache-aware verb. Unlike the single-page
|
||||
* {@link listTools} (raw `client.request`, for pagination debugging), this is
|
||||
* the path the managed tool list uses on refresh: the SDK walks every page,
|
||||
* applies the SEP-2243 `x-mcp-header` exclusion, and consults/writes its
|
||||
* response cache. `cacheMode` selects the disposition (`'use'` serves a
|
||||
* still-fresh cached list without a round trip; `'refresh'` always fetches
|
||||
* and re-stores; `'bypass'` fetches without touching the cache) — only
|
||||
* meaningful on modern servers that send `ttlMs` hints; a no-op on legacy
|
||||
* (nothing is cached, so every call hits the wire regardless).
|
||||
*/
|
||||
async listAllTools(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
}): Promise<{ tools: Tool[] }> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
}
|
||||
const response = await this.invokeMcpClient(() =>
|
||||
this.client!.listTools(
|
||||
this.aggregateListParams(options?.metadata),
|
||||
this.getCacheableRequestOptions(options?.cacheMode),
|
||||
),
|
||||
);
|
||||
return { tools: [...response.tools] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a tool. Caller must provide the Tool (e.g. from a state manager).
|
||||
* @param tool The tool to call (use tool.name for the request)
|
||||
|
|
@ -2886,6 +2948,28 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate ALL pages of `resources/list` via the SDK's high-level
|
||||
* cache-aware `client.listResources()`. See {@link listAllTools} for the
|
||||
* `cacheMode` semantics; this is the path the managed resource list uses on
|
||||
* refresh.
|
||||
*/
|
||||
async listAllResources(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
}): Promise<{ resources: Resource[] }> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
}
|
||||
const response = await this.invokeMcpClient(() =>
|
||||
this.client!.listResources(
|
||||
this.aggregateListParams(options?.metadata),
|
||||
this.getCacheableRequestOptions(options?.cacheMode),
|
||||
),
|
||||
);
|
||||
return { resources: [...response.resources] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a resource by URI
|
||||
* @param uri Resource URI
|
||||
|
|
@ -3020,6 +3104,30 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate ALL pages of `resources/templates/list` via the SDK's high-level
|
||||
* cache-aware `client.listResourceTemplates()`. See {@link listAllTools} for
|
||||
* the `cacheMode` semantics; this is the path the managed resource-template
|
||||
* list uses on refresh.
|
||||
*/
|
||||
async listAllResourceTemplates(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
}): Promise<{ resourceTemplates: ResourceTemplate[] }> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
}
|
||||
const response = await this.invokeMcpClient(
|
||||
() =>
|
||||
this.client!.listResourceTemplates(
|
||||
this.aggregateListParams(options?.metadata),
|
||||
this.getCacheableRequestOptions(options?.cacheMode),
|
||||
),
|
||||
{ method: "resources/templates/list" },
|
||||
);
|
||||
return { resourceTemplates: [...response.resourceTemplates] };
|
||||
}
|
||||
|
||||
/**
|
||||
* List available prompts with pagination support
|
||||
* @param cursor Optional cursor for pagination
|
||||
|
|
@ -3051,6 +3159,28 @@ export class InspectorClient extends InspectorClientEventTarget {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregate ALL pages of `prompts/list` via the SDK's high-level
|
||||
* cache-aware `client.listPrompts()`. See {@link listAllTools} for the
|
||||
* `cacheMode` semantics; this is the path the managed prompt list uses on
|
||||
* refresh.
|
||||
*/
|
||||
async listAllPrompts(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
}): Promise<{ prompts: Prompt[] }> {
|
||||
if (!this.client) {
|
||||
throw new Error("Client is not connected");
|
||||
}
|
||||
const response = await this.invokeMcpClient(() =>
|
||||
this.client!.listPrompts(
|
||||
this.aggregateListParams(options?.metadata),
|
||||
this.getCacheableRequestOptions(options?.cacheMode),
|
||||
),
|
||||
);
|
||||
return { prompts: [...response.prompts] };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a prompt by name
|
||||
* @param name Prompt name
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import type {
|
|||
ToolCallInvocation,
|
||||
} from "./types.js";
|
||||
import type {
|
||||
CacheMode,
|
||||
ClientCapabilities,
|
||||
DiscoverResult,
|
||||
Implementation,
|
||||
|
|
@ -89,6 +90,29 @@ export interface InspectorClientProtocol extends InspectorClientEventTarget {
|
|||
cursor?: string,
|
||||
): Promise<{ tasks: Task[]; nextCursor?: string }>;
|
||||
|
||||
// Aggregate (all-page) list methods used by the managed state stores on
|
||||
// refresh. Unlike the single-page methods above, these route through the
|
||||
// SDK's cache-aware high-level verbs so `cacheMode` ('use' | 'refresh' |
|
||||
// 'bypass') is honored — a manual refresh forces a cache-bypassing round trip
|
||||
// on modern servers that send `ttlMs` hints. Options are optional; omitting
|
||||
// `cacheMode` uses the SDK default ('use').
|
||||
listAllTools(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
}): Promise<{ tools: Tool[] }>;
|
||||
listAllPrompts(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
}): Promise<{ prompts: Prompt[] }>;
|
||||
listAllResources(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
}): Promise<{ resources: Resource[] }>;
|
||||
listAllResourceTemplates(options?: {
|
||||
cacheMode?: CacheMode;
|
||||
metadata?: Record<string, string>;
|
||||
}): Promise<{ resourceTemplates: ResourceTemplate[] }>;
|
||||
|
||||
// Invocation methods used by paged result panels
|
||||
callTool(
|
||||
tool: Tool,
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ export function headersToServerSettings(
|
|||
taskTtl: DEFAULT_TASK_TTL_MS,
|
||||
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
|
||||
autoRefreshOnListChanged: false,
|
||||
paginatedLists: false,
|
||||
roots: [],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1393,6 +1393,16 @@ export function createRemoteApp(
|
|||
error: "settings.autoRefreshOnListChanged must be a boolean",
|
||||
};
|
||||
}
|
||||
// Optional on the wire; boolean when present, else defaults to false below.
|
||||
if (
|
||||
obj.paginatedLists !== undefined &&
|
||||
typeof obj.paginatedLists !== "boolean"
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
error: "settings.paginatedLists must be a boolean",
|
||||
};
|
||||
}
|
||||
// maxFetchRequests is optional on the wire (older clients won't send it);
|
||||
// when present it must be a non-negative number (0 = unlimited), otherwise
|
||||
// it defaults below.
|
||||
|
|
@ -1477,6 +1487,8 @@ export function createRemoteApp(
|
|||
// Absent → false, matching the read side. The omit-on-false logic lives
|
||||
// in inspectorSettingsToStoredFields, so a false value writes nothing.
|
||||
autoRefreshOnListChanged: obj.autoRefreshOnListChanged === true,
|
||||
// Absent → false, matching the read side (omit-on-false on the write side).
|
||||
paginatedLists: obj.paginatedLists === true,
|
||||
// Absent → product default, matching the read side. The default is the
|
||||
// omit-sentinel in inspectorSettingsToStoredFields, so a client that
|
||||
// didn't send one writes no spurious maxFetchRequests to disk.
|
||||
|
|
|
|||
|
|
@ -120,6 +120,7 @@ type StoredInspectorFields = Pick<
|
|||
| "requestTimeout"
|
||||
| "taskTtl"
|
||||
| "autoRefreshOnListChanged"
|
||||
| "paginatedLists"
|
||||
| "maxFetchRequests"
|
||||
| "oauth"
|
||||
| "roots"
|
||||
|
|
@ -186,6 +187,7 @@ export function storedFieldsToInspectorSettings(
|
|||
stored.requestTimeout !== undefined ||
|
||||
stored.taskTtl !== undefined ||
|
||||
stored.autoRefreshOnListChanged !== undefined ||
|
||||
stored.paginatedLists !== undefined ||
|
||||
stored.maxFetchRequests !== undefined ||
|
||||
stored.oauth !== undefined ||
|
||||
stored.roots !== undefined ||
|
||||
|
|
@ -208,6 +210,7 @@ export function storedFieldsToInspectorSettings(
|
|||
// default so the form shows it and "Run as task" has a value to send.
|
||||
taskTtl: stored.taskTtl ?? DEFAULT_TASK_TTL_MS,
|
||||
autoRefreshOnListChanged: stored.autoRefreshOnListChanged ?? false,
|
||||
paginatedLists: stored.paginatedLists ?? false,
|
||||
// Concrete default like taskTtl (not a 0-sentinel): the form needs a value
|
||||
// to render and the log state needs one to size its buffer. An absent
|
||||
// on-disk field reads back as the default, which the write side then omits.
|
||||
|
|
@ -293,6 +296,11 @@ export function inspectorSettingsToStoredFields(
|
|||
out.autoRefreshOnListChanged = true;
|
||||
}
|
||||
|
||||
// Persist only when enabled — absent reads back as false (above).
|
||||
if (settings.paginatedLists) {
|
||||
out.paginatedLists = true;
|
||||
}
|
||||
|
||||
// Persist only when it differs from the default era. Absent reads back as
|
||||
// DEFAULT_PROTOCOL_ERA, so writing the default would inject the field into
|
||||
// hand-edited files that never had it and break byte-stable round-trips.
|
||||
|
|
@ -359,6 +367,7 @@ const INSPECTOR_FIELD_KEY_MAP = {
|
|||
requestTimeout: true,
|
||||
taskTtl: true,
|
||||
autoRefreshOnListChanged: true,
|
||||
paginatedLists: true,
|
||||
maxFetchRequests: true,
|
||||
oauth: true,
|
||||
roots: true,
|
||||
|
|
|
|||
|
|
@ -239,9 +239,7 @@ export class FetchRequestLogState extends TypedEventTarget<FetchRequestLogStateE
|
|||
if (restored.length === 0) return;
|
||||
const merged = [...restored, ...this.fetchRequests];
|
||||
this.fetchRequests =
|
||||
this.maxFetchRequests > 0
|
||||
? merged.slice(-this.maxFetchRequests)
|
||||
: merged;
|
||||
this.maxFetchRequests > 0 ? merged.slice(-this.maxFetchRequests) : merged;
|
||||
this.dispatchTypedEvent("fetchRequestsChange", this.getFetchRequests());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,12 +13,13 @@
|
|||
|
||||
import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
|
||||
import type { InspectorClientEventMap } from "../inspectorClientEventTarget.js";
|
||||
import type { ServerCapabilities } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
CacheMode,
|
||||
ServerCapabilities,
|
||||
} from "@modelcontextprotocol/client";
|
||||
import { isTerminalStatus } from "../types.js";
|
||||
import { TypedEventTarget } from "../typedEventTarget.js";
|
||||
|
||||
const MAX_PAGES = 100;
|
||||
|
||||
/**
|
||||
* Default delay (ms) for debouncing `list_changed` notifications. Servers
|
||||
* (e.g. the everything server) can emit a rapid burst; debouncing collapses the
|
||||
|
|
@ -33,12 +34,6 @@ export interface ManagedListEventMap {
|
|||
listChangedChange: boolean;
|
||||
}
|
||||
|
||||
/** A single page of a list result, normalized across the SDK list methods. */
|
||||
export interface ListPage<T> {
|
||||
items: T[];
|
||||
nextCursor?: string;
|
||||
}
|
||||
|
||||
export interface ManagedListConfig<T, M extends ManagedListEventMap> {
|
||||
/** The `*Change` event this manager dispatches (e.g. "toolsChange"). */
|
||||
changeEvent: keyof M;
|
||||
|
|
@ -46,14 +41,16 @@ export interface ManagedListConfig<T, M extends ManagedListEventMap> {
|
|||
listChangedEvent: keyof InspectorClientEventMap;
|
||||
/** Server capability gating the list call (empty list when absent). */
|
||||
capabilityKey: keyof ServerCapabilities;
|
||||
/** Human label used in the pagination-cap error message. */
|
||||
itemLabel: string;
|
||||
/** Fetch a single page from the client. */
|
||||
fetchPage: (
|
||||
/**
|
||||
* Fetch the complete (all-page) list from the client. Delegates page walking
|
||||
* to the SDK's cache-aware high-level verb (via the client's `listAll*`
|
||||
* methods), which also lets `cacheMode` select the cache disposition.
|
||||
*/
|
||||
fetchAll: (
|
||||
client: InspectorClientProtocol,
|
||||
cursor: string | undefined,
|
||||
cacheMode: CacheMode | undefined,
|
||||
metadata: Record<string, string> | undefined,
|
||||
) => Promise<ListPage<T>>;
|
||||
) => Promise<T[]>;
|
||||
/**
|
||||
* Whether this list drives a list-changed indicator. When true, a
|
||||
* `list_changed` in non-auto-refresh mode lights the indicator blindly (no
|
||||
|
|
@ -62,6 +59,15 @@ export interface ManagedListConfig<T, M extends ManagedListEventMap> {
|
|||
* pulled via the screen's Refresh instead.
|
||||
*/
|
||||
supportsIndicator: boolean;
|
||||
/**
|
||||
* Whether this list has a paged (paginated) counterpart that drives the
|
||||
* display when `paginatedLists` is on. Only such lists defer their
|
||||
* connect-time / `list_changed` aggregate walk in paginated mode (tools,
|
||||
* prompts, resources). Lists with no paged counterpart (resource templates)
|
||||
* set this `false` so they still aggregate on connect regardless of the
|
||||
* setting — otherwise they'd never load in paginated mode (#1721).
|
||||
*/
|
||||
deferWhenPaginated: boolean;
|
||||
/** Debounce delay (ms) for `list_changed` bursts. */
|
||||
debounceMs: number;
|
||||
}
|
||||
|
|
@ -94,6 +100,18 @@ export abstract class ManagedListState<
|
|||
this.client = client;
|
||||
this.config = config;
|
||||
const onConnect = (): void => {
|
||||
// In paginated mode the aggregate list is not the display source (the
|
||||
// paged state drives the sidebar), so skip the connect-time all-page
|
||||
// walk — the whole point of the setting is to avoid pulling every page
|
||||
// for servers with very large lists (#1721). Switching back to
|
||||
// all-pages mode triggers a refresh from the UI. Only lists with a paged
|
||||
// counterpart defer here; resource templates (none) still aggregate.
|
||||
if (
|
||||
this.config.deferWhenPaginated &&
|
||||
this.client?.getServerSettings()?.paginatedLists
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void this.refresh();
|
||||
};
|
||||
const onListChanged = (): void => {
|
||||
|
|
@ -155,10 +173,22 @@ export abstract class ManagedListState<
|
|||
try {
|
||||
do {
|
||||
this.runQueued = false;
|
||||
// Read the setting at fire time so a `setServerSettings` toggle that
|
||||
// Read the settings at fire time so a `setServerSettings` toggle that
|
||||
// lands mid-burst is honored on the settled action.
|
||||
if (this.client?.getServerSettings()?.autoRefreshOnListChanged) {
|
||||
await this.refresh();
|
||||
const settings = this.client?.getServerSettings();
|
||||
// In paginated mode the aggregate list is not the display source for
|
||||
// lists with a paged counterpart, so never auto-aggregate on
|
||||
// `list_changed` there — only light the indicator so the user can pull
|
||||
// page 1 fresh via Refresh (#1721). This wins over
|
||||
// `autoRefreshOnListChanged`, which would otherwise pull every page.
|
||||
// Lists with no paged counterpart (resource templates) still aggregate.
|
||||
const skipAggregate =
|
||||
this.config.deferWhenPaginated && settings?.paginatedLists;
|
||||
if (!skipAggregate && settings?.autoRefreshOnListChanged) {
|
||||
// A `list_changed` means the prior list is stale, so bypass any
|
||||
// cached entry (`cacheMode: "refresh"`) and re-store the fresh
|
||||
// aggregate.
|
||||
await this.refresh(undefined, "refresh");
|
||||
} else if (this.config.supportsIndicator) {
|
||||
this.setListChanged(true);
|
||||
}
|
||||
|
|
@ -210,8 +240,17 @@ export abstract class ManagedListState<
|
|||
this._metadata = metadata;
|
||||
}
|
||||
|
||||
async refresh(metadata?: Record<string, string>): Promise<T[]> {
|
||||
const next = await this.fetchAll(metadata);
|
||||
/**
|
||||
* Refresh the full list. `cacheMode` selects the SDK cache disposition for
|
||||
* this fetch: `undefined` (the connect-time load) uses the default `'use'`;
|
||||
* a user-initiated or auto refresh passes `'refresh'` to force a
|
||||
* cache-bypassing round trip and re-store the fresh aggregate.
|
||||
*/
|
||||
async refresh(
|
||||
metadata?: Record<string, string>,
|
||||
cacheMode?: CacheMode,
|
||||
): Promise<T[]> {
|
||||
const next = await this.fetchItems(metadata, cacheMode);
|
||||
// `null` means not connected — leave the current list untouched.
|
||||
if (next === null) return this.getItems();
|
||||
this.applyItems(next);
|
||||
|
|
@ -219,38 +258,21 @@ export abstract class ManagedListState<
|
|||
}
|
||||
|
||||
/**
|
||||
* Fetch all pages, then `applyItems` commits them (see `refresh`). Returns
|
||||
* `null` when not connected, or
|
||||
* `[]` when the server doesn't advertise the gating capability (calling the
|
||||
* list method there returns -32601 "Method not found", which would spam the
|
||||
* console; empty list is the right semantics).
|
||||
* Fetch the complete list (all pages, via the SDK's cache-aware verb), then
|
||||
* `applyItems` commits it (see `refresh`). Returns `null` when not connected,
|
||||
* or `[]` when the server doesn't advertise the gating capability (calling
|
||||
* the list method there returns -32601 "Method not found", which would spam
|
||||
* the console; empty list is the right semantics).
|
||||
*/
|
||||
private async fetchAll(
|
||||
metadata?: Record<string, string>,
|
||||
private async fetchItems(
|
||||
metadata: Record<string, string> | undefined,
|
||||
cacheMode: CacheMode | undefined,
|
||||
): Promise<T[] | null> {
|
||||
const client = this.client;
|
||||
if (!client || client.getStatus() !== "connected") return null;
|
||||
if (!client.getCapabilities()?.[this.config.capabilityKey]) return [];
|
||||
const effectiveMetadata = metadata ?? this._metadata;
|
||||
let items: T[] = [];
|
||||
let cursor: string | undefined;
|
||||
let pageCount = 0;
|
||||
do {
|
||||
const page = await this.config.fetchPage(
|
||||
client,
|
||||
cursor,
|
||||
effectiveMetadata,
|
||||
);
|
||||
items = cursor ? [...items, ...page.items] : page.items;
|
||||
cursor = page.nextCursor;
|
||||
pageCount++;
|
||||
if (pageCount >= MAX_PAGES) {
|
||||
throw new Error(
|
||||
`Maximum pagination limit (${MAX_PAGES} pages) reached while listing ${this.config.itemLabel}`,
|
||||
);
|
||||
}
|
||||
} while (cursor);
|
||||
return items;
|
||||
return this.config.fetchAll(client, cacheMode, effectiveMetadata);
|
||||
}
|
||||
|
||||
/** Commit a fetched list as the current one and notify subscribers. */
|
||||
|
|
|
|||
|
|
@ -33,12 +33,12 @@ export class ManagedPromptsState extends ManagedListState<
|
|||
changeEvent: "promptsChange",
|
||||
listChangedEvent: "promptsListChanged",
|
||||
capabilityKey: "prompts",
|
||||
itemLabel: "prompts",
|
||||
deferWhenPaginated: true,
|
||||
supportsIndicator: true,
|
||||
debounceMs,
|
||||
fetchPage: async (c, cursor, metadata) => {
|
||||
const result = await c.listPrompts(cursor, metadata);
|
||||
return { items: result.prompts, nextCursor: result.nextCursor };
|
||||
fetchAll: async (c, cacheMode, metadata) => {
|
||||
const result = await c.listAllPrompts({ cacheMode, metadata });
|
||||
return result.prompts;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,15 +39,17 @@ export class ManagedResourceTemplatesState extends ManagedListState<
|
|||
listChangedEvent: "resourceTemplatesListChanged",
|
||||
// Templates are gated on the broader `resources` capability.
|
||||
capabilityKey: "resources",
|
||||
itemLabel: "resource templates",
|
||||
// No paged counterpart (templates aren't paginated in the UI), so they
|
||||
// must still aggregate on connect even in paginated mode (#1721).
|
||||
deferWhenPaginated: false,
|
||||
supportsIndicator: false,
|
||||
debounceMs,
|
||||
fetchPage: async (c, cursor, metadata) => {
|
||||
const result = await c.listResourceTemplates(cursor, metadata);
|
||||
return {
|
||||
items: result.resourceTemplates,
|
||||
nextCursor: result.nextCursor,
|
||||
};
|
||||
fetchAll: async (c, cacheMode, metadata) => {
|
||||
const result = await c.listAllResourceTemplates({
|
||||
cacheMode,
|
||||
metadata,
|
||||
});
|
||||
return result.resourceTemplates;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,12 +33,12 @@ export class ManagedResourcesState extends ManagedListState<
|
|||
changeEvent: "resourcesChange",
|
||||
listChangedEvent: "resourcesListChanged",
|
||||
capabilityKey: "resources",
|
||||
itemLabel: "resources",
|
||||
deferWhenPaginated: true,
|
||||
supportsIndicator: true,
|
||||
debounceMs,
|
||||
fetchPage: async (c, cursor, metadata) => {
|
||||
const result = await c.listResources(cursor, metadata);
|
||||
return { items: result.resources, nextCursor: result.nextCursor };
|
||||
fetchAll: async (c, cacheMode, metadata) => {
|
||||
const result = await c.listAllResources({ cacheMode, metadata });
|
||||
return result.resources;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,12 +33,12 @@ export class ManagedToolsState extends ManagedListState<
|
|||
changeEvent: "toolsChange",
|
||||
listChangedEvent: "toolsListChanged",
|
||||
capabilityKey: "tools",
|
||||
itemLabel: "tools",
|
||||
deferWhenPaginated: true,
|
||||
supportsIndicator: true,
|
||||
debounceMs,
|
||||
fetchPage: async (c, cursor, metadata) => {
|
||||
const result = await c.listTools(cursor, metadata);
|
||||
return { items: result.tools, nextCursor: result.nextCursor };
|
||||
fetchAll: async (c, cacheMode, metadata) => {
|
||||
const result = await c.listAllTools({ cacheMode, metadata });
|
||||
return result.tools;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -130,9 +130,7 @@ export class MessageLogState extends TypedEventTarget<MessageLogStateEventMap> {
|
|||
*/
|
||||
clearMessages(predicate?: (entry: MessageEntry) => boolean): void {
|
||||
const before = this.messages.length;
|
||||
this.messages = predicate
|
||||
? this.messages.filter((m) => !predicate(m))
|
||||
: [];
|
||||
this.messages = predicate ? this.messages.filter((m) => !predicate(m)) : [];
|
||||
// Drop pending-request bookkeeping for any entry that was just removed.
|
||||
// Without this, a later response matching an evicted request id would
|
||||
// mutate a Map-only entry that is no longer reachable from `messages`,
|
||||
|
|
|
|||
|
|
@ -1,10 +1,12 @@
|
|||
/**
|
||||
* PagedPromptsState: holds an aggregated list of prompts loaded via loadPage(cursor).
|
||||
* Does not load on connect; caller drives loading. Clears on disconnect.
|
||||
* PagedPromptsState: holds the prompts accumulated so far, loaded one page at a
|
||||
* time via loadPage(cursor). Backs paginated mode (`paginatedLists`, #1721):
|
||||
* auto-loads page 1 on connect when the setting is on, and tracks the server's
|
||||
* `nextCursor` + a running page count as observable state. Clears on disconnect.
|
||||
*
|
||||
* Intentionally does NOT subscribe to `promptsListChanged`: cursors are tied
|
||||
* to the server's prior list, so a list change mid-pagination would invalidate
|
||||
* them. The caller decides how to react.
|
||||
* them. The caller pulls page 1 again via Refresh instead.
|
||||
*
|
||||
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
|
||||
* concrete `InspectorClient` since the runtime class is not yet ported.
|
||||
|
|
@ -14,9 +16,11 @@ import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
|
|||
import type { Prompt } from "@modelcontextprotocol/client";
|
||||
import { isTerminalStatus } from "../types.js";
|
||||
import { TypedEventTarget } from "../typedEventTarget.js";
|
||||
import type { PagePaginationState } from "./pagedToolsState.js";
|
||||
|
||||
export interface PagedPromptsStateEventMap {
|
||||
promptsChange: Prompt[];
|
||||
paginationChange: PagePaginationState;
|
||||
}
|
||||
|
||||
export interface LoadPageResult {
|
||||
|
|
@ -26,21 +30,32 @@ export interface LoadPageResult {
|
|||
|
||||
export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMap> {
|
||||
private prompts: Prompt[] = [];
|
||||
private nextCursor: string | undefined = undefined;
|
||||
private pageCount = 0;
|
||||
// Double-click guard: a load in flight makes the next `loadPage` a no-op so
|
||||
// the same page can't be appended twice (#1721).
|
||||
private loading = false;
|
||||
private client: InspectorClientProtocol | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
constructor(client: InspectorClientProtocol) {
|
||||
super();
|
||||
this.client = client;
|
||||
const onStatusChange = (): void => {
|
||||
if (isTerminalStatus(this.client?.getStatus())) {
|
||||
this.prompts = [];
|
||||
this.dispatchTypedEvent("promptsChange", []);
|
||||
const onConnect = (): void => {
|
||||
if (this.client?.getServerSettings()?.paginatedLists) {
|
||||
void this.loadPage(undefined);
|
||||
}
|
||||
};
|
||||
const onStatusChange = (): void => {
|
||||
if (isTerminalStatus(this.client?.getStatus())) {
|
||||
this.reset();
|
||||
}
|
||||
};
|
||||
this.client.addEventListener("connect", onConnect);
|
||||
this.client.addEventListener("statusChange", onStatusChange);
|
||||
this.unsubscribe = () => {
|
||||
if (this.client) {
|
||||
this.client.removeEventListener("connect", onConnect);
|
||||
this.client.removeEventListener("statusChange", onStatusChange);
|
||||
}
|
||||
this.client = null;
|
||||
|
|
@ -51,9 +66,20 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa
|
|||
return [...this.prompts];
|
||||
}
|
||||
|
||||
getPagination(): PagePaginationState {
|
||||
return { nextCursor: this.nextCursor, pageCount: this.pageCount };
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.prompts = [];
|
||||
this.nextCursor = undefined;
|
||||
this.pageCount = 0;
|
||||
this.dispatchTypedEvent("promptsChange", this.prompts);
|
||||
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
||||
}
|
||||
|
||||
async loadPage(
|
||||
|
|
@ -64,18 +90,31 @@ export class PagedPromptsState extends TypedEventTarget<PagedPromptsStateEventMa
|
|||
if (!c || c.getStatus() !== "connected") {
|
||||
return { prompts: [], nextCursor: undefined };
|
||||
}
|
||||
const result = await c.listPrompts(cursor, metadata);
|
||||
this.prompts =
|
||||
cursor === undefined
|
||||
? [...result.prompts]
|
||||
: [...this.prompts, ...result.prompts];
|
||||
this.dispatchTypedEvent("promptsChange", this.prompts);
|
||||
return { prompts: result.prompts, nextCursor: result.nextCursor };
|
||||
if (this.loading) {
|
||||
return { prompts: [], nextCursor: this.nextCursor };
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
const result = await c.listPrompts(cursor, metadata);
|
||||
this.prompts =
|
||||
cursor === undefined
|
||||
? [...result.prompts]
|
||||
: [...this.prompts, ...result.prompts];
|
||||
this.pageCount = cursor === undefined ? 1 : this.pageCount + 1;
|
||||
this.nextCursor = result.nextCursor;
|
||||
this.dispatchTypedEvent("promptsChange", this.prompts);
|
||||
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
||||
return { prompts: result.prompts, nextCursor: result.nextCursor };
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
this.prompts = [];
|
||||
this.nextCursor = undefined;
|
||||
this.pageCount = 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
/**
|
||||
* PagedResourcesState: holds an aggregated list of resources loaded via loadPage(cursor).
|
||||
* Does not load on connect; caller drives loading. Clears on disconnect.
|
||||
* PagedResourcesState: holds the resources accumulated so far, loaded one page
|
||||
* at a time via loadPage(cursor). Backs paginated mode (`paginatedLists`,
|
||||
* #1721): auto-loads page 1 on connect when the setting is on, and tracks the
|
||||
* server's `nextCursor` + a running page count as observable state. Clears on
|
||||
* disconnect.
|
||||
*
|
||||
* Intentionally does NOT subscribe to `resourcesListChanged`: cursors are
|
||||
* tied to the server's prior list, so a list change mid-pagination would
|
||||
* invalidate them. The caller decides how to react.
|
||||
* Intentionally does NOT subscribe to `resourcesListChanged`: cursors are tied
|
||||
* to the server's prior list, so a list change mid-pagination would invalidate
|
||||
* them. The caller pulls page 1 again via Refresh instead.
|
||||
*
|
||||
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
|
||||
* concrete `InspectorClient` since the runtime class is not yet ported.
|
||||
|
|
@ -14,9 +17,11 @@ import type { InspectorClientProtocol } from "../inspectorClientProtocol.js";
|
|||
import type { Resource } from "@modelcontextprotocol/client";
|
||||
import { isTerminalStatus } from "../types.js";
|
||||
import { TypedEventTarget } from "../typedEventTarget.js";
|
||||
import type { PagePaginationState } from "./pagedToolsState.js";
|
||||
|
||||
export interface PagedResourcesStateEventMap {
|
||||
resourcesChange: Resource[];
|
||||
paginationChange: PagePaginationState;
|
||||
}
|
||||
|
||||
export interface LoadPageResult {
|
||||
|
|
@ -26,21 +31,32 @@ export interface LoadPageResult {
|
|||
|
||||
export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEventMap> {
|
||||
private resources: Resource[] = [];
|
||||
private nextCursor: string | undefined = undefined;
|
||||
private pageCount = 0;
|
||||
// Double-click guard: a load in flight makes the next `loadPage` a no-op so
|
||||
// the same page can't be appended twice (#1721).
|
||||
private loading = false;
|
||||
private client: InspectorClientProtocol | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
constructor(client: InspectorClientProtocol) {
|
||||
super();
|
||||
this.client = client;
|
||||
const onStatusChange = (): void => {
|
||||
if (isTerminalStatus(this.client?.getStatus())) {
|
||||
this.resources = [];
|
||||
this.dispatchTypedEvent("resourcesChange", []);
|
||||
const onConnect = (): void => {
|
||||
if (this.client?.getServerSettings()?.paginatedLists) {
|
||||
void this.loadPage(undefined);
|
||||
}
|
||||
};
|
||||
const onStatusChange = (): void => {
|
||||
if (isTerminalStatus(this.client?.getStatus())) {
|
||||
this.reset();
|
||||
}
|
||||
};
|
||||
this.client.addEventListener("connect", onConnect);
|
||||
this.client.addEventListener("statusChange", onStatusChange);
|
||||
this.unsubscribe = () => {
|
||||
if (this.client) {
|
||||
this.client.removeEventListener("connect", onConnect);
|
||||
this.client.removeEventListener("statusChange", onStatusChange);
|
||||
}
|
||||
this.client = null;
|
||||
|
|
@ -51,9 +67,20 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve
|
|||
return [...this.resources];
|
||||
}
|
||||
|
||||
getPagination(): PagePaginationState {
|
||||
return { nextCursor: this.nextCursor, pageCount: this.pageCount };
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.resources = [];
|
||||
this.nextCursor = undefined;
|
||||
this.pageCount = 0;
|
||||
this.dispatchTypedEvent("resourcesChange", this.resources);
|
||||
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
||||
}
|
||||
|
||||
async loadPage(
|
||||
|
|
@ -64,18 +91,31 @@ export class PagedResourcesState extends TypedEventTarget<PagedResourcesStateEve
|
|||
if (!c || c.getStatus() !== "connected") {
|
||||
return { resources: [], nextCursor: undefined };
|
||||
}
|
||||
const result = await c.listResources(cursor, metadata);
|
||||
this.resources =
|
||||
cursor === undefined
|
||||
? [...result.resources]
|
||||
: [...this.resources, ...result.resources];
|
||||
this.dispatchTypedEvent("resourcesChange", this.resources);
|
||||
return { resources: result.resources, nextCursor: result.nextCursor };
|
||||
if (this.loading) {
|
||||
return { resources: [], nextCursor: this.nextCursor };
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
const result = await c.listResources(cursor, metadata);
|
||||
this.resources =
|
||||
cursor === undefined
|
||||
? [...result.resources]
|
||||
: [...this.resources, ...result.resources];
|
||||
this.pageCount = cursor === undefined ? 1 : this.pageCount + 1;
|
||||
this.nextCursor = result.nextCursor;
|
||||
this.dispatchTypedEvent("resourcesChange", this.resources);
|
||||
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
||||
return { resources: result.resources, nextCursor: result.nextCursor };
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
this.resources = [];
|
||||
this.nextCursor = undefined;
|
||||
this.pageCount = 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
/**
|
||||
* PagedToolsState: holds an aggregated list of tools loaded via loadPage(cursor).
|
||||
* Does not load on connect; caller drives loading. Clears on disconnect.
|
||||
* PagedToolsState: holds the tools accumulated so far, loaded one page at a time
|
||||
* via loadPage(cursor). Backs paginated mode (the `paginatedLists` setting,
|
||||
* #1721): auto-loads page 1 on connect when the setting is on, and tracks the
|
||||
* server's `nextCursor` + a running page count as observable state so the
|
||||
* sidebar can surface a "Load next page" control. Clears on disconnect.
|
||||
*
|
||||
* Intentionally does NOT subscribe to `toolsListChanged`: cursors are tied to
|
||||
* the server's prior list, so a list change mid-pagination would invalidate
|
||||
* them. The caller decides how to react (typically by `clear()` + reload
|
||||
* page 1) — see the managed variant for the auto-refresh shape.
|
||||
* them. The caller pulls page 1 again via Refresh instead (the managed variant
|
||||
* owns the auto-refresh shape).
|
||||
*
|
||||
* Ported from v1.5/main. v2 substitutes `InspectorClientProtocol` for the
|
||||
* concrete `InspectorClient` since the runtime class is not yet ported.
|
||||
|
|
@ -16,8 +19,17 @@ import type { Tool } from "@modelcontextprotocol/client";
|
|||
import { isTerminalStatus } from "../types.js";
|
||||
import { TypedEventTarget } from "../typedEventTarget.js";
|
||||
|
||||
/** Observable pagination progress for a paged list. */
|
||||
export interface PagePaginationState {
|
||||
/** The server's `nextCursor` from the last page (undefined = at the end). */
|
||||
nextCursor?: string;
|
||||
/** Number of pages loaded since the last reset (page 1 = 1). */
|
||||
pageCount: number;
|
||||
}
|
||||
|
||||
export interface PagedToolsStateEventMap {
|
||||
toolsChange: Tool[];
|
||||
paginationChange: PagePaginationState;
|
||||
}
|
||||
|
||||
export interface LoadPageResult {
|
||||
|
|
@ -27,21 +39,35 @@ export interface LoadPageResult {
|
|||
|
||||
export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
|
||||
private tools: Tool[] = [];
|
||||
private nextCursor: string | undefined = undefined;
|
||||
private pageCount = 0;
|
||||
// Guards against a concurrent load (e.g. a fast double-click on "Load next
|
||||
// page"): both calls would otherwise read the same cursor and append the
|
||||
// same page twice. A load in flight makes the next `loadPage` a no-op (#1721).
|
||||
private loading = false;
|
||||
private client: InspectorClientProtocol | null = null;
|
||||
private unsubscribe: (() => void) | null = null;
|
||||
|
||||
constructor(client: InspectorClientProtocol) {
|
||||
super();
|
||||
this.client = client;
|
||||
const onStatusChange = (): void => {
|
||||
if (isTerminalStatus(this.client?.getStatus())) {
|
||||
this.tools = [];
|
||||
this.dispatchTypedEvent("toolsChange", []);
|
||||
const onConnect = (): void => {
|
||||
// Auto-load page 1 only in paginated mode — otherwise the managed
|
||||
// (aggregate) state is the display source and this stays idle (#1721).
|
||||
if (this.client?.getServerSettings()?.paginatedLists) {
|
||||
void this.loadPage(undefined);
|
||||
}
|
||||
};
|
||||
const onStatusChange = (): void => {
|
||||
if (isTerminalStatus(this.client?.getStatus())) {
|
||||
this.reset();
|
||||
}
|
||||
};
|
||||
this.client.addEventListener("connect", onConnect);
|
||||
this.client.addEventListener("statusChange", onStatusChange);
|
||||
this.unsubscribe = () => {
|
||||
if (this.client) {
|
||||
this.client.removeEventListener("connect", onConnect);
|
||||
this.client.removeEventListener("statusChange", onStatusChange);
|
||||
}
|
||||
this.client = null;
|
||||
|
|
@ -52,9 +78,21 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
|
|||
return [...this.tools];
|
||||
}
|
||||
|
||||
getPagination(): PagePaginationState {
|
||||
return { nextCursor: this.nextCursor, pageCount: this.pageCount };
|
||||
}
|
||||
|
||||
/** Clear the accumulated list and pagination progress. */
|
||||
clear(): void {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.tools = [];
|
||||
this.nextCursor = undefined;
|
||||
this.pageCount = 0;
|
||||
this.dispatchTypedEvent("toolsChange", this.tools);
|
||||
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
||||
}
|
||||
|
||||
async loadPage(cursor?: string): Promise<LoadPageResult> {
|
||||
|
|
@ -62,18 +100,35 @@ export class PagedToolsState extends TypedEventTarget<PagedToolsStateEventMap> {
|
|||
if (!c || c.getStatus() !== "connected") {
|
||||
return { tools: [], nextCursor: undefined };
|
||||
}
|
||||
const result = await c.listTools(cursor, undefined);
|
||||
this.tools =
|
||||
cursor === undefined
|
||||
? [...result.tools]
|
||||
: [...this.tools, ...result.tools];
|
||||
this.dispatchTypedEvent("toolsChange", this.tools);
|
||||
return { tools: result.tools, nextCursor: result.nextCursor };
|
||||
// Drop a concurrent load (double-click guard) — the in-flight one owns the
|
||||
// current cursor; return it so callers don't misread this as "the end".
|
||||
if (this.loading) {
|
||||
return { tools: [], nextCursor: this.nextCursor };
|
||||
}
|
||||
this.loading = true;
|
||||
try {
|
||||
const result = await c.listTools(cursor, undefined);
|
||||
// An undefined cursor is page 1 — replace the list and reset the count;
|
||||
// a cursor appends the next page.
|
||||
this.tools =
|
||||
cursor === undefined
|
||||
? [...result.tools]
|
||||
: [...this.tools, ...result.tools];
|
||||
this.pageCount = cursor === undefined ? 1 : this.pageCount + 1;
|
||||
this.nextCursor = result.nextCursor;
|
||||
this.dispatchTypedEvent("toolsChange", this.tools);
|
||||
this.dispatchTypedEvent("paginationChange", this.getPagination());
|
||||
return { tools: result.tools, nextCursor: result.nextCursor };
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
this.unsubscribe?.();
|
||||
this.unsubscribe = null;
|
||||
this.tools = [];
|
||||
this.nextCursor = undefined;
|
||||
this.pageCount = 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,14 @@ export type StoredMCPServer = MCPServerConfig & {
|
|||
* false (the default). (#1402)
|
||||
*/
|
||||
autoRefreshOnListChanged?: boolean;
|
||||
/**
|
||||
* When true, the tools/resources/prompts lists are fetched one page at a time
|
||||
* (a manual "Load next page" control surfaces the server's `nextCursor`)
|
||||
* instead of auto-aggregating every page on load. A defensive default for
|
||||
* servers with very large lists. Inspector-specific. Omitted on disk when
|
||||
* false (the default). (#1721)
|
||||
*/
|
||||
paginatedLists?: boolean;
|
||||
/**
|
||||
* Maximum number of HTTP fetch requests retained in the Network log for this
|
||||
* server (oldest rotate out past the cap). Inspector-specific. Omitted on
|
||||
|
|
@ -545,6 +553,12 @@ export interface InspectorServerSettings {
|
|||
* and the user pulls the new list via Refresh. (#1402)
|
||||
*/
|
||||
autoRefreshOnListChanged?: boolean;
|
||||
/**
|
||||
* When true, the tools/resources/prompts lists fetch one page at a time (a
|
||||
* manual "Load next page" control) instead of auto-aggregating all pages.
|
||||
* Default false. Server-wide; the per-list sidebar toggle edits this. (#1721)
|
||||
*/
|
||||
paginatedLists?: boolean;
|
||||
/**
|
||||
* Maximum number of HTTP fetch requests retained in the Network log for this
|
||||
* server. When exceeded, the oldest entries rotate out (and any deferred
|
||||
|
|
|
|||
Loading…
Reference in a new issue