inspector/core/mcp/node/servers.ts
Cliff Hall b3b04c6939
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) (#1723)
* 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>
2026-07-19 01:06:31 -04:00

173 lines
6 KiB
TypeScript

import {
KeyringSecretStore,
type SecretStore,
} from "../../auth/node/secret-store.js";
import type { InspectorServerSettings, MCPServerConfig } from "../types.js";
import { DEFAULT_MAX_FETCH_REQUESTS, DEFAULT_TASK_TTL_MS } from "../types.js";
import { mcpConfigToServerEntries } from "../serverList.js";
import {
applyOverrides,
resolveServerConfigs,
resolveServerSource,
serverSourceConflict,
readServerListFile,
hasAdHocServerOptions,
withDefaultCatalogPath,
type ServerConfigOptions,
} from "./config.js";
import { rehydrateMcpConfigFromKeychain } from "./server-secrets.js";
/**
* A server resolved from a catalog/config file or an ad-hoc target, paired with
* the per-server settings (headers, timeouts, OAuth, etc.) lifted from the file.
* `config` is the SDK-facing transport config; `settings` is the v2
* `InspectorServerSettings` model (post-#1358 top-level shape).
*/
export type ResolvedServer = {
config: MCPServerConfig;
settings?: InspectorServerSettings;
};
/** Loader options: the shared source flags plus a single `--header` set that is
* broadcast into every resolved server's settings. */
export type ServerLoadOptions = ServerConfigOptions & {
headers?: Record<string, string>;
/** Test injection; defaults to {@link KeyringSecretStore} for catalog/config loads. */
secretStore?: SecretStore;
};
/**
* Build an `InspectorServerSettings` carrying only `--header` overrides (all
* other fields defaulted). Returns undefined when no headers are given so it can
* be used as a no-op base in `mergeSettings`.
*/
export function headersToServerSettings(
headers?: Record<string, string>,
): InspectorServerSettings | undefined {
if (!headers || Object.keys(headers).length === 0) {
return undefined;
}
return {
headers: Object.entries(headers).map(([key, value]) => ({ key, value })),
env: [],
metadata: [],
connectionTimeout: 0,
requestTimeout: 0,
taskTtl: DEFAULT_TASK_TTL_MS,
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
autoRefreshOnListChanged: false,
paginatedLists: false,
roots: [],
};
}
/**
* Overlay CLI `--header` values onto the settings lifted from the file. Only the
* `headers` field is overridden — timeouts, OAuth, and the rest of the file's
* settings are preserved.
*/
function mergeSettings(
base: InspectorServerSettings | undefined,
headers?: Record<string, string>,
): InspectorServerSettings | undefined {
const fromHeaders = headersToServerSettings(headers);
if (!fromHeaders) return base;
if (!base) return fromHeaders;
return { ...base, headers: fromHeaders.headers };
}
/**
* Resolve every server from a catalog/config file (or a single ad-hoc target)
* into `{ config, settings }`, lifting disk-level headers/timeouts/OAuth into
* `InspectorServerSettings` via `mcpConfigToServerEntries`. Shared by the CLI
* and TUI so both apply the same file→settings resolution (issue #1482).
*
* Applies the default writable catalog when no source/ad-hoc target is given,
* enforces the `--catalog`/`--config`/ad-hoc conflict matrix, and seeds an empty
* writable catalog on first run (a missing read-only `--config` still errors).
*/
export async function loadServerEntries(
serverOptions: ServerLoadOptions,
): Promise<Record<string, ResolvedServer>> {
serverOptions = withDefaultCatalogPath(serverOptions);
const conflict = serverSourceConflict({
hasCatalog: Boolean(serverOptions.catalogPath?.trim()),
hasConfig: Boolean(serverOptions.configPath?.trim()),
hasAdHoc: hasAdHocServerOptions(serverOptions),
});
if (conflict) {
throw new Error(conflict);
}
const source = resolveServerSource(serverOptions);
if (source) {
let config = readServerListFile(source.path, source.writable);
const secretStore = serverOptions.secretStore ?? new KeyringSecretStore();
config = await rehydrateMcpConfigFromKeychain(config, secretStore);
const entries = mcpConfigToServerEntries(config);
const result: Record<string, ResolvedServer> = {};
for (const entry of entries) {
result[entry.name] = {
config: applyOverrides(entry.config, {
env: serverOptions.env,
cwd: serverOptions.cwd,
}),
// Deliberate broadcast: a single `--header` set is merged into EVERY
// server in the catalog/config (fine for the common single-server case;
// for multi-server files, prefer per-server headers in the file itself).
settings: mergeSettings(entry.settings, serverOptions.headers),
};
}
return result;
}
const configs = resolveServerConfigs(serverOptions, "multi");
if (configs.length === 0) {
throw new Error(
"At least one server is required. Use --catalog/--config <path> or an ad-hoc target (command/URL).",
);
}
return {
default: {
config: configs[0]!,
settings: headersToServerSettings(serverOptions.headers),
},
};
}
/**
* Pick a single resolved server for runners that connect to exactly one (the
* CLI). With `serverName`, returns that entry or errors listing the available
* names; otherwise requires exactly one server in the source.
*
* The unknown-name error is source-agnostic ("not found" rather than "not found
* in config file") because `entries` may come from a file *or* a single ad-hoc
* target — e.g. `--server foo` alongside a positional command resolves to just
* `{ default }`, where "in config file" would be misleading.
*/
export function selectServerEntry(
entries: Record<string, ResolvedServer>,
serverName?: string,
): ResolvedServer {
const names = Object.keys(entries);
if (serverName) {
const entry = entries[serverName];
if (!entry) {
throw new Error(
`Server '${serverName}' not found. Available servers: ${names.join(", ")}`,
);
}
return entry;
}
if (names.length === 0) {
throw new Error("No servers found in config file");
}
if (names.length > 1) {
throw new Error(
`Multiple servers found in config file. Please specify one with --server. Available servers: ${names.join(", ")}`,
);
}
return entries[names[0]!]!;
}