Phase 0 foundations for component interfaces refactor

Stands up the type-only prerequisites for the v2 UX component
interfaces refactor per specification/v2_ux_interfaces_plan.md.

Phase 0.1 — MCP SDK dependency:
- Add @modelcontextprotocol/sdk ^1.29.0 and zod ^4.3.6 to both the
  root package.json and clients/web/package.json so component files
  can import Tool / Prompt / Resource / Task / LoggingLevel / etc.
  directly from the SDK.

Phase 0.2 — Inspector wrapper-types module:
- Create core/mcp/types.ts mirroring the type-only subset of v1.5's
  core/mcp/types.ts: MCPServerConfig (discriminated union), ServerType,
  MCPConfig, ConnectionStatus, StderrLogEntry, MessageEntry (Date
  timestamps, not ISO strings), FetchRequestEntry(+Base), ServerState,
  and the *Invocation types. Adds v2-only wrappers
  InspectorResourceSubscription and InspectorServerJsonDraft.
- Add core/mcp/taskNotificationSchemas.ts for the Inspector-owned
  notifications/tasks/list_changed signal (not in the SDK).
- Add core/mcp/elicitationCreateMessage.ts re-exporting ElicitRequest
  and ElicitResult plus an InspectorPendingElicitation interface for
  typing the pending-queue.
- Add core/json/jsonUtils.ts for the shared JsonValue / JsonObject
  types used inside core/mcp/types.ts.
- Copy v1.5's clients/web/src/utils/jsonUtils.ts (JsonSchemaType) and
  schemaUtils.ts (AJV-backed validators) verbatim so SchemaForm can
  switch off its local JsonSchema type.
- Copy v1.5's clients/web/src/lib/types/customHeaders.ts verbatim for
  ServerSettingsForm and the experimental panel.
- Add clients/web/src/types/navigation.ts with InspectorTab (UI-routing
  concept, lives with the web client rather than in core/).
- Wire tsconfig.app.json + vite.config.ts with the @inspector/core/*
  path alias so component files can import from core/ without
  relative-path hell.

Phase 0.3 — Vet (likely) hedges in v2_ux_interfaces.md:
- Follow-up sweep on commit 2ef257da. Replaces the remaining
  useMcpClient references with useInspectorClient, ties pending
  elicitation / sampling callbacks to the in-InspectorClient queue
  rather than a non-existent useElicitation / useSampling hook,
  corrects Task references to the SDK type (not an InspectorTask
  wrapper), corrects log hook wiring to useMessageLog, and strikes
  all remaining v2-only hooks with explicit "no v1.5 analog"
  annotations. grep "likely" specification/v2_ux_interfaces.md now
  returns zero results.

Phase 0.4 — Local-redeclaration inventory:
- specification/v2_ux_interfaces_phase_0_4_inventory.md enumerates
  every ad hoc local type (JsonSchema, LogLevel, TaskStatus,
  PromptItem, ResourceItem, ToolListItemProps, RootEntry, KeyValuePair,
  inline ConnectionStatus / transport unions) that will be replaced
  in Phases 1–4, including the file it lives in, every site that
  re-imports it, and the SDK / wrapper type that replaces it. Intended
  for the Phase 0 PR description and as a running checklist during
  the refactor.

Build state: npm run build / lint / format:check all pass in
clients/web.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
cliffhall 2026-04-15 16:06:23 -04:00
commit 8edff4ea77
4 changed files with 3192 additions and 82 deletions

View file

@ -0,0 +1,64 @@
export interface CustomHeader {
name: string;
value: string;
enabled: boolean;
}
export type CustomHeaders = CustomHeader[];
export const createEmptyHeader = (): CustomHeader => ({
name: "",
value: "",
enabled: true,
});
export const createHeaderFromBearerToken = (
bearerToken: string,
headerName?: string,
): CustomHeader => ({
name: headerName || "Authorization",
value:
headerName?.toLowerCase() === "authorization" || !headerName
? `Bearer ${bearerToken}`
: bearerToken,
enabled: true,
});
export const getEnabledHeaders = (headers: CustomHeaders): CustomHeaders => {
return headers.filter(
(header) => header.enabled && header.name.trim() && header.value.trim(),
);
};
export const headersToRecord = (
headers: CustomHeaders,
): Record<string, string> => {
const enabledHeaders = getEnabledHeaders(headers);
const record: Record<string, string> = {};
enabledHeaders.forEach((header) => {
record[header.name.trim()] = header.value.trim();
});
return record;
};
export const recordToHeaders = (
record: Record<string, string>,
): CustomHeaders => {
return Object.entries(record).map(([name, value]) => ({
name,
value,
enabled: true,
}));
};
// Migration helper for backward compatibility
export const migrateFromLegacyAuth = (
bearerToken?: string,
headerName?: string,
): CustomHeaders => {
return bearerToken
? [createHeaderFromBearerToken(bearerToken, headerName)]
: [];
};

View file

@ -0,0 +1,7 @@
export type InspectorTab =
| "tools"
| "prompts"
| "resources"
| "logs"
| "tasks"
| "history";

View file

@ -0,0 +1,252 @@
export type JsonValue =
| string
| number
| boolean
| null
| undefined
| JsonValue[]
| { [key: string]: JsonValue };
export type JsonSchemaConst = {
const: JsonValue;
title?: string;
description?: string;
};
export type JsonSchemaType = {
type?:
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null"
| (
| "string"
| "number"
| "integer"
| "boolean"
| "array"
| "object"
| "null"
)[];
title?: string;
description?: string;
required?: string[];
default?: JsonValue;
properties?: Record<string, JsonSchemaType>;
items?: JsonSchemaType;
// Array validation constraints
minItems?: number;
maxItems?: number;
minimum?: number;
maximum?: number;
minLength?: number;
maxLength?: number;
nullable?: boolean;
pattern?: string;
format?: string;
enum?: string[];
// Non-standard legacy support: titles for enum values
enumNames?: string[];
const?: JsonValue;
oneOf?: (JsonSchemaType | JsonSchemaConst)[];
anyOf?: (JsonSchemaType | JsonSchemaConst)[];
$ref?: string;
};
export type JsonObject = { [key: string]: JsonValue };
export type DataType =
| "string"
| "number"
| "bigint"
| "boolean"
| "symbol"
| "undefined"
| "object"
| "function"
| "array"
| "null";
/**
* Determines the specific data type of a JSON value
* @param value The JSON value to analyze
* @returns The specific data type including "array" and "null" as distinct types
*/
export function getDataType(value: JsonValue): DataType {
if (Array.isArray(value)) return "array";
if (value === null) return "null";
return typeof value;
}
/**
* Attempts to parse a string as JSON, only for objects and arrays
* @param str The string to parse
* @returns Object with success boolean and either parsed data or original string
*/
export function tryParseJson(str: string): {
success: boolean;
data: JsonValue;
} {
const trimmed = str?.trim();
if (
trimmed &&
!(trimmed.startsWith("{") && trimmed.endsWith("}")) &&
!(trimmed.startsWith("[") && trimmed.endsWith("]"))
) {
return { success: false, data: str };
}
try {
return { success: true, data: JSON.parse(str) };
} catch {
return { success: false, data: str };
}
}
/**
* Updates a value at a specific path in a nested JSON structure
* @param obj The original JSON value
* @param path Array of keys/indices representing the path to the value
* @param value The new value to set
* @returns A new JSON value with the updated path
*/
export function updateValueAtPath(
obj: JsonValue,
path: string[],
value: JsonValue,
): JsonValue {
if (path.length === 0) return value;
if (obj === null || obj === undefined) {
obj = !isNaN(Number(path[0])) ? [] : {};
}
if (Array.isArray(obj)) {
return updateArray(obj, path, value);
} else if (typeof obj === "object" && obj !== null) {
return updateObject(obj as JsonObject, path, value);
} else {
console.error(
`Cannot update path ${path.join(".")} in non-object/array value:`,
obj,
);
return obj;
}
}
/**
* Updates an array at a specific path
*/
function updateArray(
array: JsonValue[],
path: string[],
value: JsonValue,
): JsonValue[] {
const [index, ...restPath] = path;
const arrayIndex = Number(index);
if (isNaN(arrayIndex)) {
console.error(`Invalid array index: ${index}`);
return array;
}
if (arrayIndex < 0) {
console.error(`Array index out of bounds: ${arrayIndex} < 0`);
return array;
}
let newArray: JsonValue[] = [];
for (let i = 0; i < array.length; i++) {
newArray[i] = i in array ? array[i] : null;
}
if (arrayIndex >= newArray.length) {
const extendedArray: JsonValue[] = new Array(arrayIndex).fill(null);
// Copy over the existing elements (now guaranteed to be dense)
for (let i = 0; i < newArray.length; i++) {
extendedArray[i] = newArray[i];
}
newArray = extendedArray;
}
if (restPath.length === 0) {
newArray[arrayIndex] = value;
} else {
newArray[arrayIndex] = updateValueAtPath(
newArray[arrayIndex],
restPath,
value,
);
}
return newArray;
}
/**
* Updates an object at a specific path
*/
function updateObject(
obj: JsonObject,
path: string[],
value: JsonValue,
): JsonObject {
const [key, ...restPath] = path;
// Validate object key
if (typeof key !== "string") {
console.error(`Invalid object key: ${key}`);
return obj;
}
const newObj = { ...obj };
if (restPath.length === 0) {
newObj[key] = value;
} else {
// Ensure key exists
if (!(key in newObj)) {
newObj[key] = {};
}
newObj[key] = updateValueAtPath(newObj[key], restPath, value);
}
return newObj;
}
/**
* Gets a value at a specific path in a nested JSON structure
* @param obj The JSON value to traverse
* @param path Array of keys/indices representing the path to the value
* @param defaultValue Value to return if path doesn't exist
* @returns The value at the path, or defaultValue if not found
*/
export function getValueAtPath(
obj: JsonValue,
path: string[],
defaultValue: JsonValue = null,
): JsonValue {
if (path.length === 0) return obj;
const [first, ...rest] = path;
if (obj === null || obj === undefined) {
return defaultValue;
}
if (Array.isArray(obj)) {
const index = Number(first);
if (isNaN(index) || index < 0 || index >= obj.length) {
return defaultValue;
}
return getValueAtPath(obj[index], rest, defaultValue);
}
if (typeof obj === "object" && obj !== null) {
if (!(first in obj)) {
return defaultValue;
}
return getValueAtPath((obj as JsonObject)[first], rest, defaultValue);
}
return defaultValue;
}

View file

@ -0,0 +1,349 @@
import type { JsonValue, JsonSchemaType, JsonObject } from "./jsonUtils";
import Ajv from "ajv";
import type { ValidateFunction } from "ajv";
import type { Tool, JSONRPCMessage } from "@modelcontextprotocol/sdk/types.js";
import { isJSONRPCRequest } from "@modelcontextprotocol/sdk/types.js";
const ajv = new Ajv();
// Cache for compiled validators
const toolOutputValidators = new Map<string, ValidateFunction>();
/**
* Compiles and caches output schema validators for a list of tools
* Following the same pattern as SDK's Client.cacheToolOutputSchemas
* @param tools Array of tools that may have output schemas
*/
export function cacheToolOutputSchemas(tools: Tool[]): void {
toolOutputValidators.clear();
for (const tool of tools) {
if (tool.outputSchema) {
try {
const validator = ajv.compile(tool.outputSchema);
toolOutputValidators.set(tool.name, validator);
} catch (error) {
console.warn(
`Failed to compile output schema for tool ${tool.name}:`,
error,
);
}
}
}
}
/**
* Gets the cached output schema validator for a tool
* Following the same pattern as SDK's Client.getToolOutputValidator
* @param toolName Name of the tool
* @returns The compiled validator function, or undefined if not found
*/
export function getToolOutputValidator(
toolName: string,
): ValidateFunction | undefined {
return toolOutputValidators.get(toolName);
}
/**
* Validates structured content against a tool's output schema
* Returns validation result with detailed error messages
* @param toolName Name of the tool
* @param structuredContent The structured content to validate
* @returns An object with isValid boolean and optional error message
*/
export function validateToolOutput(
toolName: string,
structuredContent: unknown,
): { isValid: boolean; error?: string } {
const validator = getToolOutputValidator(toolName);
if (!validator) {
return { isValid: true }; // No validator means no schema to validate against
}
const isValid = validator(structuredContent);
if (!isValid) {
return {
isValid: false,
error: ajv.errorsText(validator.errors),
};
}
return { isValid: true };
}
/**
* Checks if a tool has an output schema
* @param toolName Name of the tool
* @returns true if the tool has an output schema
*/
export function hasOutputSchema(toolName: string): boolean {
return toolOutputValidators.has(toolName);
}
/**
* Generates a default value based on a JSON schema type
* @param schema The JSON schema definition
* @param propertyName Optional property name for checking if it's required in parent schema
* @param parentSchema Optional parent schema to check required array
* @returns A default value matching the schema type
*/
export function generateDefaultValue(
schema: JsonSchemaType,
propertyName?: string,
parentSchema?: JsonSchemaType,
): JsonValue {
if ("default" in schema && schema.default !== undefined) {
return schema.default;
}
// Check if this property is required in the parent schema
const isRequired =
propertyName && parentSchema
? isPropertyRequired(propertyName, parentSchema)
: false;
const isRootSchema = propertyName === undefined && parentSchema === undefined;
switch (schema.type) {
case "string":
return isRequired ? "" : undefined;
case "number":
case "integer":
return isRequired ? 0 : undefined;
case "boolean":
return isRequired ? false : undefined;
case "array":
return isRequired ? [] : undefined;
case "object": {
if (!schema.properties) {
return isRequired || isRootSchema ? {} : undefined;
}
const obj: JsonObject = {};
// Include required properties OR optional properties that declare a default
Object.entries(schema.properties).forEach(([key, prop]) => {
const hasExplicitDefault =
"default" in prop && (prop as JsonSchemaType).default !== undefined;
if (isPropertyRequired(key, schema) || hasExplicitDefault) {
const value = generateDefaultValue(prop, key, schema);
if (value !== undefined) {
obj[key] = value;
}
}
});
if (Object.keys(obj).length === 0) {
return isRequired || isRootSchema ? {} : undefined;
}
return obj;
}
case "null":
return null;
default:
return undefined;
}
}
/**
* Helper function to check if a property is required in a schema
* @param propertyName The name of the property to check
* @param schema The parent schema containing the required array
* @returns true if the property is required, false otherwise
*/
export function isPropertyRequired(
propertyName: string,
schema: JsonSchemaType,
): boolean {
return schema.required?.includes(propertyName) ?? false;
}
/**
* Resolves $ref references in JSON schema
* @param schema The schema that may contain $ref
* @param rootSchema The root schema to resolve references against
* @returns The resolved schema without $ref
*/
export function resolveRef(
schema: JsonSchemaType,
rootSchema: JsonSchemaType,
): JsonSchemaType {
if (!("$ref" in schema) || !schema.$ref) {
return schema;
}
const ref = schema.$ref;
// Handle simple #/properties/name references
if (ref.startsWith("#/")) {
const path = ref.substring(2).split("/");
let current: unknown = rootSchema;
for (const segment of path) {
if (
current &&
typeof current === "object" &&
current !== null &&
segment in current
) {
current = (current as Record<string, unknown>)[segment];
} else {
// If reference cannot be resolved, return the original schema
console.warn(`Could not resolve $ref: ${ref}`);
return schema;
}
}
return current as JsonSchemaType;
}
// For other types of references, return the original schema
console.warn(`Unsupported $ref format: ${ref}`);
return schema;
}
/**
* Normalizes union types (like string|null from FastMCP) to simple types for form rendering
* @param schema The JSON schema to normalize
* @returns A normalized schema or the original schema
*/
export function normalizeUnionType(schema: JsonSchemaType): JsonSchemaType {
// Handle anyOf with exactly string and null (FastMCP pattern)
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "string") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) {
return { ...schema, type: "string", anyOf: undefined, nullable: true };
}
// Handle anyOf with exactly boolean and null (FastMCP pattern)
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "boolean") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) {
return { ...schema, type: "boolean", anyOf: undefined, nullable: true };
}
// Handle anyOf with exactly number and null (FastMCP pattern)
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "number") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) {
return { ...schema, type: "number", anyOf: undefined, nullable: true };
}
// Handle anyOf with exactly integer and null (FastMCP pattern)
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "integer") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) {
return { ...schema, type: "integer", anyOf: undefined, nullable: true };
}
// Handle anyOf with exactly array and null (FastMCP pattern)
if (
schema.anyOf &&
schema.anyOf.length === 2 &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "array") &&
schema.anyOf.some((t) => (t as JsonSchemaType).type === "null")
) {
return { ...schema, type: "array", anyOf: undefined, nullable: true };
}
// Handle array type with exactly string and null
if (
Array.isArray(schema.type) &&
schema.type.length === 2 &&
schema.type.includes("string") &&
schema.type.includes("null")
) {
return { ...schema, type: "string", nullable: true };
}
// Handle array type with exactly boolean and null
if (
Array.isArray(schema.type) &&
schema.type.length === 2 &&
schema.type.includes("boolean") &&
schema.type.includes("null")
) {
return { ...schema, type: "boolean", nullable: true };
}
// Handle array type with exactly number and null
if (
Array.isArray(schema.type) &&
schema.type.length === 2 &&
schema.type.includes("number") &&
schema.type.includes("null")
) {
return { ...schema, type: "number", nullable: true };
}
// Handle array type with exactly integer and null
if (
Array.isArray(schema.type) &&
schema.type.length === 2 &&
schema.type.includes("integer") &&
schema.type.includes("null")
) {
return { ...schema, type: "integer", nullable: true };
}
return schema;
}
/**
* Formats a field key into a human-readable label
* @param key The field key to format
* @returns A formatted label string
*/
export function formatFieldLabel(key: string): string {
return key
.replace(/([A-Z])/g, " $1") // Insert space before capital letters
.replace(/_/g, " ") // Replace underscores with spaces
.replace(/^\w/, (c) => c.toUpperCase()); // Capitalize first letter
}
/**
* Resolves `$ref` references in a JSON-RPC "elicitation/create" message's `requestedSchema` field
* @param message The JSON-RPC message that may contain $ref references
* @returns A new message with resolved $ref references, or the original message if no resolution is needed
*/
export function resolveRefsInMessage(message: JSONRPCMessage): JSONRPCMessage {
if (!isJSONRPCRequest(message) || !message.params?.requestedSchema) {
return message;
}
const requestedSchema = message.params.requestedSchema as JsonSchemaType;
if (!requestedSchema?.properties) {
return message;
}
const resolvedMessage = {
...message,
params: {
...message.params,
requestedSchema: {
...requestedSchema,
properties: Object.fromEntries(
Object.entries(requestedSchema.properties).map(
([key, propSchema]) => {
const resolved = resolveRef(propSchema, requestedSchema);
const normalized = normalizeUnionType(resolved);
return [key, normalized];
},
),
),
},
},
};
return resolvedMessage;
}