inspector/core/mcp/inspectorClientProtocol.ts
cliffhall 9a8488681e feat: Skills extension phase 3 — CLI, TUI, directory reads, frontmatter check
Closes #2248.

Completes SEP-2640 support across all three clients, and closes the one
obligation #2234 deliberately left open.

- `checkSkillFrontmatterMatch` compares a served SKILL.md's own YAML
  frontmatter against the entry the listing advertised, field by field. No
  digest can cover this: a digest is taken over the bytes the server served,
  so it proves the file was not altered in transit and says nothing about
  whether the listing described it honestly. Needs a real YAML parser; `yaml`
  was already a root dependency, so this adds no package — but it does newly
  put it on core/'s import graph, hence the three bundler `external` lists.

- `resources/directory/read`: result schemas defined against the normative
  text, `InspectorClient.readResourceDirectory`, a Directory section on the
  Skills screen, and `--method resources/directory/read` in the CLI. The
  client refuses the call locally when the server did not declare
  `directoryRead`, which is the SEP's MUST NOT. A child the directory lists
  but the entry does not is marked `not listed` rather than merged into the
  manifest — the SEP calls a directory result a live observation and forbids
  treating it as extending the manifest.

- CLI: `skills/list`, `skills/get` and `resources/directory/read`, plus
  `--verify` — one NDJSON report per skill, a summary on stderr, exit 7 on a
  violation. Reads are sequential; a conforming manifest may declare 512
  entries.

- TUI: a Skills pane, shown only when the server declares the extension. Each
  row carries its conformance verdict as a glyph as well as a colour, since
  the pane is read over ssh and through script(1). Enter verifies.

- `verifySkills` re-throws `AuthRecoveryRequiredError` rather than recording
  it per file: it says the session's authorization expired, so absorbing it
  would report N identical read failures and swallow the error the TUI and
  the web commands key off to reauthorize.

Two open questions settled, both in code comments where they will be found:
`skills/get` carries no caching attributes because SEP-2640 leaves the
question open in as many words, so requiring them would fail a conforming
server; and there is no `PagedSkillsState` because every consumer of this
list is a whole-catalog verdict — computed over page one of three, "this
server's skills conform" is not merely partial but wrong.

The fixture grows two skills, both for checks that were otherwise
undemonstrable: `lying-listing` (listing and file disagree, digest still
verifies) and `stale-manifest` (serves a file its manifest does not declare).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-07 18:10:37 -04:00

215 lines
8.5 KiB
TypeScript

/**
* InspectorClientProtocol: the minimal surface that state managers (core/mcp/state/)
* and React hooks (core/react/) consume.
*
* v1.5 has a single concrete `InspectorClient` class (~2k LOC) bundling the transport,
* SDK client, OAuth flow, pending-request queues, and fetch tracking. Porting the full
* class requires first porting the auth subsystem (core/auth/, ~12 files) — that's
* deferred to a follow-up issue.
*
* This interface captures just what the state+hooks layer needs to function,
* so the layer is decoupled from how (or whether) the runtime is wired up.
* The real `InspectorClient` will implement this interface in the follow-up port.
*/
import type {
ConnectionStatus,
InspectorServerSettings,
ResourceReadInvocation,
ResourceTemplateReadInvocation,
PromptGetInvocation,
ToolCallInvocation,
ResourceSubscriptionStreamState,
ExcludedTool,
RequestMetadata,
} from "./types.js";
import type {
CacheMode,
ClientCapabilities,
DiscoverResult,
Implementation,
LoggingLevel,
Prompt,
ProtocolEra,
Resource,
ResourceTemplateType as ResourceTemplate,
ServerCapabilities,
Task,
Tool,
} from "@modelcontextprotocol/client";
import type { JsonValue } from "../json/jsonUtils.js";
import type { MalformedListItem } from "./listSalvage.js";
import type { InspectorClientEventTarget } from "./inspectorClientEventTarget.js";
import type { SkillEntry } from "./skillsSchemas.js";
import type { SkillsExtensionSupport } from "./skills.js";
import type { DirectoryReadResult } from "./skillsSchemas.js";
import type { SamplingCreateMessage } from "./samplingCreateMessage.js";
import type { ElicitationCreateMessage } from "./elicitationCreateMessage.js";
/**
* Opaque type representing the AppRendererClient surface used by @mcp-ui.
* v1.5 aliases this to the SDK `Client` type; v2 leaves it opaque until the
* real InspectorClient (or a focused App-renderer port) lands.
*/
export type AppRendererClient = unknown;
/**
* The contract every state manager and hook depends on. Anything that holds
* a reference to "an Inspector client" — including tests and fixtures —
* should depend on this protocol rather than the concrete class.
*/
export interface InspectorClientProtocol extends InspectorClientEventTarget {
// Connection state accessors
getStatus(): ConnectionStatus;
getCapabilities(): ServerCapabilities | undefined;
getClientCapabilities(): ClientCapabilities;
getServerInfo(): Implementation | undefined;
getInstructions(): string | undefined;
getProtocolVersion(): string | undefined;
getProtocolEra(): ProtocolEra | undefined;
/** Tools excluded from `tools/list` for invalid `x-mcp-header` annotations
* (SEP-2243); empty on legacy/stdio connections (#1632). */
getExcludedTools(): ExcludedTool[];
/** Entries dropped from a list result as malformed, across every list method;
* empty against a conforming server (#1909). */
getMalformedListItems(): MalformedListItem[];
getResourceSubscriptionStreamState(): ResourceSubscriptionStreamState;
getDiscoverResult(): DiscoverResult | undefined;
getServerSettings(): InspectorServerSettings | undefined;
setServerSettings(settings: InspectorServerSettings): void;
getAppRendererClient(): AppRendererClient | null;
// Connection control
connect(): Promise<void>;
disconnect(safeDisconnectTimeout?: number): Promise<void>;
// Paginated list methods used by managed/paged state stores
listTools(
cursor?: string,
metadata?: RequestMetadata,
): Promise<{ tools: Tool[]; nextCursor?: string }>;
listPrompts(
cursor?: string,
metadata?: RequestMetadata,
): Promise<{ prompts: Prompt[]; nextCursor?: string }>;
listResources(
cursor?: string,
metadata?: RequestMetadata,
): Promise<{ resources: Resource[]; nextCursor?: string }>;
listResourceTemplates(
cursor?: string,
metadata?: RequestMetadata,
): Promise<{ resourceTemplates: ResourceTemplate[]; nextCursor?: string }>;
listRequestorTasks(
cursor?: string,
): Promise<{ tasks: Task[]; nextCursor?: string }>;
/** Poll one requestor task's current status (era-aware: modern `DetailedTask`
* via `tasks/get`, or the legacy flattened task). Dispatches
* `requestorTaskUpdated`. Used by the modern task store's refresh (no
* `tasks/list`). */
getRequestorTask(taskId: string): Promise<Task>;
/** True when a modern (2026-07-28) connection negotiated the
* `io.modelcontextprotocol/tasks` extension (SEP-2663). Gates the Tasks tab
* and the modern task store's poll-based refresh. */
isTasksExtensionNegotiated(): boolean;
/** The Skills extension (SEP-2640) the server declared, or `undefined`.
* Gates the Skills tab and the managed skills store (#2234). */
getSkillsExtension(): SkillsExtensionSupport | undefined;
/** One page of `skills/list`; the managed skills store walks the cursor. */
listSkills(
cursor?: string,
metadata?: RequestMetadata,
): Promise<{ skills: SkillEntry[]; nextCursor?: string }>;
/** One skill entry by URI (`skills/get`). */
getSkill(uri: string, metadata?: RequestMetadata): Promise<SkillEntry>;
/**
* One page of `resources/directory/read` — the direct children of a directory
* resource (#2248). Optional on this interface, unlike the two methods above:
* declaring the extension commits a server to `skills/list` and `skills/get`,
* while this one is separately gated on `directoryRead`, so a caller has to
* check for it anyway and the many test doubles that satisfy this interface
* should not all have to grow a method most of them never reach.
*/
readResourceDirectory?(
uri: string,
cursor?: string,
metadata?: RequestMetadata,
): Promise<DirectoryReadResult>;
/**
* Mark the response that most recently answered `method` as rejected by the
* client, so its Protocol entry shows the reason instead of rendering as a
* clean success (#1953). Optional so existing test doubles satisfy the
* interface without implementing it; see `InspectorClient` for how the id is
* correlated and why that correlation is exact.
*/
markResponseRejected?(method: string, reason: string): void;
// 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?: RequestMetadata;
}): Promise<{ tools: Tool[] }>;
listAllPrompts(options?: {
cacheMode?: CacheMode;
metadata?: RequestMetadata;
}): Promise<{ prompts: Prompt[] }>;
listAllResources(options?: {
cacheMode?: CacheMode;
metadata?: RequestMetadata;
}): Promise<{ resources: Resource[] }>;
listAllResourceTemplates(options?: {
cacheMode?: CacheMode;
metadata?: RequestMetadata;
}): Promise<{ resourceTemplates: ResourceTemplate[] }>;
// Invocation methods used by paged result panels
callTool(
tool: Tool,
args: Record<string, JsonValue>,
generalMetadata?: RequestMetadata,
toolSpecificMetadata?: RequestMetadata,
): Promise<ToolCallInvocation>;
readResource(
uri: string,
metadata?: RequestMetadata,
): Promise<ResourceReadInvocation>;
readResourceFromTemplate(
uriTemplate: string,
params: Record<string, string>,
metadata?: RequestMetadata,
): Promise<ResourceTemplateReadInvocation>;
getPrompt(
name: string,
params?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<PromptGetInvocation>;
// Misc surface required by hooks/state
setLoggingLevel(level: LoggingLevel): Promise<void>;
getSessionId(): string | undefined;
// Server-initiated request queues. Each entry exposes `respond()` / `reject()`
// (and `remove()`), which resolve the handler Promise that left the originating
// call (e.g. a tool execution) in flight. The `pendingSamplesChange` /
// `pendingElicitationsChange` events fire whenever these arrays mutate.
getPendingSamples(): SamplingCreateMessage[];
getPendingElicitations(): ElicitationCreateMessage[];
// Completions (resource templates / prompt arguments)
getCompletions(
ref:
| { type: "ref/resource"; uri: string }
| { type: "ref/prompt"; name: string },
argumentName: string,
argumentValue: string,
context?: Record<string, string>,
metadata?: RequestMetadata,
): Promise<{ values: string[]; total?: number; hasMore?: boolean }>;
}