diff --git a/core/package.json b/core/package.json new file mode 100644 index 000000000..f2e5be8a3 --- /dev/null +++ b/core/package.json @@ -0,0 +1,33 @@ +{ + "name": "@modelcontextprotocol/inspector-core", + "version": "0.1.0", + "description": "Core MCP client logic for Inspector - no React dependencies", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./types": { + "types": "./dist/types/index.d.ts", + "import": "./dist/types/index.js" + } + }, + "scripts": { + "build": "tsc", + "clean": "rm -rf dist", + "prepublishOnly": "npm run build" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "^1.25.1" + }, + "devDependencies": { + "typescript": "^5.6.3" + }, + "files": [ + "dist", + "src" + ] +} diff --git a/core/src/client.ts b/core/src/client.ts new file mode 100644 index 000000000..6796af849 --- /dev/null +++ b/core/src/client.ts @@ -0,0 +1,154 @@ +/** + * MCP Client Wrapper - Manage MCP client lifecycle and capabilities + * + * This module wraps the MCP SDK Client for React usage, handling: + * - Client creation with proper capabilities + * - Connection lifecycle (connect/disconnect) + * - Server info extraction + * + * Usage: + * import { createMcpClient, connectClient } from '@/lib/mcp/client'; + * + * const client = createMcpClient(); + * const transport = createHttpTransport(url); + * await connectClient(client, transport); + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'; +import type { Implementation, ServerCapabilities } from '@modelcontextprotocol/sdk/types.js'; + +/** + * Client configuration options. + */ +export interface McpClientOptions { + /** Client name shown to servers */ + name?: string; + /** Client version */ + version?: string; + /** Enable sampling capability (receive LLM requests from server) */ + enableSampling?: boolean; + /** Enable roots capability (expose filesystem paths to server) */ + enableRoots?: boolean; +} + +/** + * Information about a connected MCP server. + */ +export interface ServerInfo { + /** Server name */ + name: string; + /** Server version */ + version: string; + /** Server capabilities */ + capabilities: ServerCapabilities; + /** Server instructions (if provided) */ + instructions?: string; +} + +const DEFAULT_CLIENT_NAME = 'MCP Inspector'; +const DEFAULT_CLIENT_VERSION = '2.0.0'; + +/** + * Create a new MCP client with Inspector capabilities. + * + * @param options - Client configuration options + * @returns Configured MCP Client instance + */ +export function createMcpClient(options: McpClientOptions = {}): Client { + const { + name = DEFAULT_CLIENT_NAME, + version = DEFAULT_CLIENT_VERSION, + enableSampling = true, + enableRoots = true, + } = options; + + const clientInfo: Implementation = { + name, + version, + }; + + // Build capabilities based on options + const capabilities: Record = {}; + + if (enableSampling) { + // Declare sampling capability with tool support (per MCP 2025-11-25 spec) + capabilities.sampling = { tools: {} }; + } + + if (enableRoots) { + // Declare roots capability + capabilities.roots = { listChanged: true }; + } + + return new Client(clientInfo, { capabilities }); +} + +/** + * Connect an MCP client to a server via transport. + * + * @param client - MCP Client instance + * @param transport - Transport to connect through + * @returns Server information on successful connection + * @throws Error if connection fails + */ +export async function connectClient( + client: Client, + transport: Transport +): Promise { + await client.connect(transport); + + const serverVersion = client.getServerVersion(); + const serverCapabilities = client.getServerCapabilities(); + const instructions = client.getInstructions(); + + if (!serverVersion) { + throw new Error('Failed to get server version after connection'); + } + + return { + name: serverVersion.name, + version: serverVersion.version, + capabilities: serverCapabilities ?? {}, + instructions: instructions ?? undefined, + }; +} + +/** + * Disconnect an MCP client and clean up resources. + * + * @param client - MCP Client instance to disconnect + */ +export async function disconnectClient(client: Client): Promise { + await client.close(); +} + +/** + * Check if a client is currently connected. + * + * @param client - MCP Client instance + * @returns true if connected + */ +export function isClientConnected(client: Client): boolean { + // The SDK client doesn't expose connection state directly, + // but we can check if server version is available + try { + return client.getServerVersion() !== undefined; + } catch { + return false; + } +} + +/** + * Check if server supports a specific capability. + * + * @param serverInfo - Server info from connection + * @param capability - Capability name to check + * @returns true if capability is supported + */ +export function serverSupports( + serverInfo: ServerInfo, + capability: keyof ServerCapabilities +): boolean { + return serverInfo.capabilities[capability] !== undefined; +} diff --git a/core/src/data/index.ts b/core/src/data/index.ts new file mode 100644 index 000000000..ff6dab5ea --- /dev/null +++ b/core/src/data/index.ts @@ -0,0 +1,44 @@ +/** + * Data Layer Interfaces and Memory Stubs + * + * This module provides: + * - Repository interfaces for data storage (CRUD contracts) + * - Service interfaces for stateful business logic + * - Memory stub implementations for development/testing + * + * Real implementations (proxy API, file-based, localStorage) can be added later. + */ + +// Repository interfaces +export type { + ServerConfigRepository, + HistoryRepository, + HistoryListOptions, + LogsRepository, + LogsListOptions, + TestingProfileRepository, +} from './repositories.js'; + +// Service interfaces +export type { + ConnectionService, + ConnectionState, + ConnectionError, + ConnectionOptions, + ExecutionService, + ExecutionState, + PendingClientRequest, +} from './services.js'; + +// Initial state exports +export { initialConnectionState, initialExecutionState } from './services.js'; + +// Memory stub implementations +export { + createMemoryServerConfigRepository, + createMemoryHistoryRepository, + createMemoryLogsRepository, + createMemoryTestingProfileRepository, + createMemoryConnectionService, + createMemoryExecutionService, +} from './memory.js'; diff --git a/core/src/data/memory.ts b/core/src/data/memory.ts new file mode 100644 index 000000000..8e2ee854b --- /dev/null +++ b/core/src/data/memory.ts @@ -0,0 +1,528 @@ +/** + * Memory Stub Implementations + * + * In-memory implementations of repository and service interfaces. + * Use for development, testing, or as a starting point for real implementations. + * Data is lost when the process exits. + */ + +import type { + ServerConfigRepository, + HistoryRepository, + HistoryListOptions, + LogsRepository, + LogsListOptions, + TestingProfileRepository, +} from './repositories.js'; +import type { + ConnectionService, + ConnectionState, + ConnectionOptions, + ExecutionService, + ExecutionState, + PendingClientRequest, + initialConnectionState, + initialExecutionState, +} from './services.js'; +import type { ServerConfig } from '../types/servers.js'; +import type { HistoryEntry } from '../types/history.js'; +import type { LogEntry, LOG_LEVELS } from '../types/logs.js'; +import type { TestingProfile } from '../types/testingProfiles.js'; +import type { ServerInfo } from '../client.js'; +import { + createMcpClient, + connectClient, + disconnectClient, + createHttpTransport, + createAuthenticatedTransport, +} from '../index.js'; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +function generateId(prefix: string): string { + return `${prefix}-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} + +// ============================================================================ +// Server Config Repository +// ============================================================================ + +/** + * In-memory server config repository. + */ +export function createMemoryServerConfigRepository(): ServerConfigRepository { + const configs = new Map(); + + return { + async list() { + return Array.from(configs.values()); + }, + + async get(id) { + return configs.get(id); + }, + + async create(config) { + const now = new Date().toISOString(); + const full: ServerConfig = { + ...config, + id: generateId('server'), + createdAt: now, + updatedAt: now, + connectionMode: config.transport === 'http' ? 'direct' : 'proxy', + }; + configs.set(full.id, full); + return full; + }, + + async update(id, updates) { + const existing = configs.get(id); + if (!existing) { + throw new Error(`Server config not found: ${id}`); + } + const updated: ServerConfig = { + ...existing, + ...updates, + id: existing.id, + createdAt: existing.createdAt, + updatedAt: new Date().toISOString(), + }; + configs.set(id, updated); + return updated; + }, + + async delete(id) { + configs.delete(id); + }, + + async deleteAll() { + configs.clear(); + }, + }; +} + +// ============================================================================ +// History Repository +// ============================================================================ + +/** + * In-memory history repository. + */ +export function createMemoryHistoryRepository(): HistoryRepository { + const entries = new Map(); + + return { + async list(options?: HistoryListOptions) { + let result = Array.from(entries.values()); + + // Filter by method + if (options?.method) { + result = result.filter((e) => e.method === options.method); + } + + // Filter by request type + if (options?.requestType) { + result = result.filter((e) => e.requestType === options.requestType); + } + + // Filter root only + if (options?.rootOnly) { + result = result.filter((e) => !e.parentRequestId); + } + + // Sort by timestamp descending (newest first) + result.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + + // Apply limit + if (options?.limit && options.limit > 0) { + result = result.slice(0, options.limit); + } + + return result; + }, + + async get(id) { + return entries.get(id); + }, + + async add(entry) { + const full: HistoryEntry = { + ...entry, + id: generateId('req'), + timestamp: new Date().toISOString(), + pinned: false, + }; + entries.set(full.id, full); + return full; + }, + + async update(id, updates) { + const existing = entries.get(id); + if (!existing) { + throw new Error(`History entry not found: ${id}`); + } + const updated: HistoryEntry = { + ...existing, + ...updates, + id: existing.id, + timestamp: existing.timestamp, + }; + entries.set(id, updated); + return updated; + }, + + async delete(id) { + entries.delete(id); + }, + + async deleteAll(options) { + if (options?.keepPinned) { + for (const [id, entry] of entries) { + if (!entry.pinned) { + entries.delete(id); + } + } + } else { + entries.clear(); + } + }, + + async getChildren(parentRequestId) { + return Array.from(entries.values()).filter( + (e) => e.parentRequestId === parentRequestId + ); + }, + }; +} + +// ============================================================================ +// Logs Repository +// ============================================================================ + +/** + * In-memory logs repository. + */ +export function createMemoryLogsRepository(): LogsRepository { + const logs: LogEntry[] = []; + const LOG_LEVEL_ORDER = [ + 'debug', + 'info', + 'notice', + 'warning', + 'error', + 'critical', + 'alert', + 'emergency', + ] as const; + + return { + async list(options?: LogsListOptions) { + let result = [...logs]; + + // Filter by minimum level + if (options?.minLevel) { + const minIndex = LOG_LEVEL_ORDER.indexOf(options.minLevel); + result = result.filter((log) => { + const logIndex = LOG_LEVEL_ORDER.indexOf( + log.level as (typeof LOG_LEVEL_ORDER)[number] + ); + return logIndex >= minIndex; + }); + } + + // Filter by logger + if (options?.logger) { + result = result.filter((log) => log.logger === options.logger); + } + + // Filter by request ID + if (options?.requestId) { + result = result.filter((log) => log.requestId === options.requestId); + } + + // Sort by timestamp descending + result.sort((a, b) => b.timestamp.localeCompare(a.timestamp)); + + // Apply limit + if (options?.limit && options.limit > 0) { + result = result.slice(0, options.limit); + } + + return result; + }, + + async add(entry) { + const full: LogEntry = { + ...entry, + timestamp: new Date().toISOString(), + }; + logs.push(full); + return full; + }, + + async addBatch(entries) { + const timestamp = new Date().toISOString(); + const fullEntries = entries.map((entry) => ({ + ...entry, + timestamp, + })); + logs.push(...fullEntries); + return fullEntries; + }, + + async deleteAll() { + logs.length = 0; + }, + + async getForRequest(requestId) { + return logs.filter( + (log) => + log.requestId === requestId || log.parentRequestId === requestId + ); + }, + }; +} + +// ============================================================================ +// Testing Profile Repository +// ============================================================================ + +/** + * In-memory testing profile repository. + */ +export function createMemoryTestingProfileRepository(): TestingProfileRepository { + const profiles = new Map(); + + return { + async list() { + return Array.from(profiles.values()); + }, + + async get(id) { + return profiles.get(id); + }, + + async create(profile) { + const full: TestingProfile = { + ...profile, + id: generateId('profile'), + }; + profiles.set(full.id, full); + return full; + }, + + async update(id, updates) { + const existing = profiles.get(id); + if (!existing) { + throw new Error(`Testing profile not found: ${id}`); + } + const updated: TestingProfile = { + ...existing, + ...updates, + id: existing.id, + }; + profiles.set(id, updated); + return updated; + }, + + async delete(id) { + profiles.delete(id); + }, + }; +} + +// ============================================================================ +// Connection Service +// ============================================================================ + +/** + * In-memory connection service. + * Wraps the actual MCP client with state tracking. + */ +export function createMemoryConnectionService(): ConnectionService { + let state: ConnectionState = { + status: 'disconnected', + serverUrl: null, + serverInfo: null, + error: null, + }; + const listeners = new Set<() => void>(); + let clientRef: ReturnType | null = null; + + function notify() { + listeners.forEach((l) => l()); + } + + return { + getState() { + return { ...state }; + }, + + async connect(url, options) { + // Update state to connecting + state = { + status: 'connecting', + serverUrl: url, + serverInfo: null, + error: null, + }; + notify(); + + try { + // Create client and transport + const client = createMcpClient(); + const transport = options?.token + ? createAuthenticatedTransport(url, options.token) + : createHttpTransport(url, options?.headers); + + // Connect + const serverInfo = await connectClient(client, transport); + clientRef = client; + + // Update state to connected + state = { + status: 'connected', + serverUrl: url, + serverInfo, + error: null, + }; + notify(); + + return serverInfo; + } catch (err) { + // Update state to error + state = { + status: 'error', + serverUrl: url, + serverInfo: null, + error: { + message: err instanceof Error ? err.message : 'Connection failed', + timestamp: new Date().toISOString(), + }, + }; + notify(); + throw err; + } + }, + + async disconnect() { + if (clientRef) { + try { + await disconnectClient(clientRef); + } catch { + // Ignore disconnect errors + } + clientRef = null; + } + + state = { + status: 'disconnected', + serverUrl: null, + serverInfo: null, + error: null, + }; + notify(); + }, + + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} + +// ============================================================================ +// Execution Service +// ============================================================================ + +/** + * In-memory execution service. + */ +export function createMemoryExecutionService(): ExecutionService { + let state: ExecutionState = { + currentRequestId: null, + isExecuting: false, + pendingClientRequests: [], + }; + const listeners = new Set<() => void>(); + + function notify() { + listeners.forEach((l) => l()); + } + + return { + getState() { + return { ...state, pendingClientRequests: [...state.pendingClientRequests] }; + }, + + startExecution(requestId) { + state = { + ...state, + currentRequestId: requestId, + isExecuting: true, + }; + notify(); + }, + + endExecution() { + state = { + ...state, + currentRequestId: null, + isExecuting: false, + }; + notify(); + }, + + cancelExecution() { + state = { + currentRequestId: null, + isExecuting: false, + pendingClientRequests: [], + }; + notify(); + }, + + addPendingRequest(request) { + state = { + ...state, + pendingClientRequests: [...state.pendingClientRequests, request], + }; + notify(); + }, + + resolvePendingRequest(id) { + state = { + ...state, + pendingClientRequests: state.pendingClientRequests.map((r) => + r.id === id ? { ...r, status: 'resolved' as const } : r + ), + }; + notify(); + }, + + rejectPendingRequest(id) { + state = { + ...state, + pendingClientRequests: state.pendingClientRequests.map((r) => + r.id === id ? { ...r, status: 'rejected' as const } : r + ), + }; + notify(); + }, + + clearPendingRequests() { + state = { + ...state, + pendingClientRequests: [], + }; + notify(); + }, + + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + }; +} diff --git a/core/src/data/repositories.ts b/core/src/data/repositories.ts new file mode 100644 index 000000000..0ea103dc5 --- /dev/null +++ b/core/src/data/repositories.ts @@ -0,0 +1,197 @@ +/** + * Repository Interfaces for Inspector Data Layer + * + * These interfaces define CRUD contracts for data storage. + * Implementations can use proxy API, file storage, localStorage, etc. + * Memory stubs provided for development and testing. + */ + +import type { ServerConfig } from '../types/servers.js'; +import type { HistoryEntry } from '../types/history.js'; +import type { LogEntry, LogLevel } from '../types/logs.js'; +import type { TestingProfile } from '../types/testingProfiles.js'; + +/** + * Server configuration repository. + * + * Manages persisted server configurations. + * Implementations: proxy API, file-based, localStorage, memory stub + */ +export interface ServerConfigRepository { + /** + * List all server configurations. + */ + list(): Promise; + + /** + * Get a server configuration by ID. + */ + get(id: string): Promise; + + /** + * Create a new server configuration. + * Returns the created config with generated ID and timestamps. + */ + create( + config: Omit + ): Promise; + + /** + * Update an existing server configuration. + * Returns the updated config with new updatedAt timestamp. + */ + update( + id: string, + updates: Partial> + ): Promise; + + /** + * Delete a server configuration. + */ + delete(id: string): Promise; + + /** + * Delete all server configurations. + */ + deleteAll(): Promise; +} + +/** + * History repository. + * + * Manages request/response history entries with hierarchical relationships. + * Implementations: proxy API (NDJSON), file-based, localStorage, memory stub + */ +export interface HistoryRepository { + /** + * List history entries with optional filtering. + */ + list(options?: HistoryListOptions): Promise; + + /** + * Get a history entry by ID. + */ + get(id: string): Promise; + + /** + * Add a new history entry. + * Returns the entry with generated ID and timestamp. + */ + add(entry: Omit): Promise; + + /** + * Update an existing history entry. + */ + update( + id: string, + updates: Partial> + ): Promise; + + /** + * Delete a history entry. + */ + delete(id: string): Promise; + + /** + * Delete all history entries. + * @param options.keepPinned If true, keep pinned entries + */ + deleteAll(options?: { keepPinned?: boolean }): Promise; + + /** + * Get children of a parent request (for hierarchical display). + */ + getChildren(parentRequestId: string): Promise; +} + +export interface HistoryListOptions { + /** Maximum number of entries to return */ + limit?: number; + /** Filter by MCP method (e.g., 'tools/call') */ + method?: string; + /** Filter by request type */ + requestType?: 'primary' | 'client'; + /** Only return root entries (no parentRequestId) */ + rootOnly?: boolean; +} + +/** + * Logs repository. + * + * Manages protocol event logs with RFC 5424 levels. + * Implementations: proxy API, file-based, localStorage, memory stub + */ +export interface LogsRepository { + /** + * List log entries with optional filtering. + */ + list(options?: LogsListOptions): Promise; + + /** + * Add a single log entry. + */ + add(entry: Omit): Promise; + + /** + * Add multiple log entries in batch. + */ + addBatch(entries: Array>): Promise; + + /** + * Delete all log entries. + */ + deleteAll(): Promise; + + /** + * Get logs for a specific request chain (parent + children). + */ + getForRequest(requestId: string): Promise; +} + +export interface LogsListOptions { + /** Maximum number of entries to return */ + limit?: number; + /** Minimum log level (includes this level and more severe) */ + minLevel?: LogLevel; + /** Filter by logger category */ + logger?: string; + /** Filter by request ID */ + requestId?: string; +} + +/** + * Testing profiles repository. + * + * Manages testing profiles for sampling/elicitation response strategies. + * Implementations: proxy API, file-based, localStorage, memory stub + */ +export interface TestingProfileRepository { + /** + * List all testing profiles. + */ + list(): Promise; + + /** + * Get a testing profile by ID. + */ + get(id: string): Promise; + + /** + * Create a new testing profile. + * Returns the created profile with generated ID. + */ + create(profile: Omit): Promise; + + /** + * Update an existing testing profile. + */ + update( + id: string, + updates: Partial> + ): Promise; + + /** + * Delete a testing profile. + */ + delete(id: string): Promise; +} diff --git a/core/src/data/services.ts b/core/src/data/services.ts new file mode 100644 index 000000000..b6c0a664c --- /dev/null +++ b/core/src/data/services.ts @@ -0,0 +1,177 @@ +/** + * Service Interfaces for Inspector Business Logic + * + * These interfaces define stateful services for connection and execution management. + * They wrap core MCP client functions with state tracking and event subscription. + */ + +import type { ServerInfo } from '../client.js'; +import type { SamplingRequest, ElicitationRequest } from '../types/clientRequests.js'; + +/** + * Connection service - manages MCP client connections. + * + * Wraps core client functions with state tracking and event subscription. + * Implementations can use different storage for connection state. + */ +export interface ConnectionService { + /** + * Get current connection state. + */ + getState(): ConnectionState; + + /** + * Connect to an MCP server. + * @param url Server URL + * @param options Connection options (headers, token, etc.) + */ + connect(url: string, options?: ConnectionOptions): Promise; + + /** + * Disconnect from the current server. + */ + disconnect(): Promise; + + /** + * Subscribe to state changes. + * Returns unsubscribe function. + */ + subscribe(listener: () => void): () => void; +} + +/** + * Connection state + */ +export interface ConnectionState { + /** Connection status */ + status: 'disconnected' | 'connecting' | 'connected' | 'error'; + /** Currently connected server URL */ + serverUrl: string | null; + /** Server info from successful connection */ + serverInfo: ServerInfo | null; + /** Error details if status is 'error' */ + error: ConnectionError | null; +} + +/** + * Connection error details + */ +export interface ConnectionError { + message: string; + code?: string; + timestamp: string; +} + +/** + * Connection options + */ +export interface ConnectionOptions { + /** Custom headers for HTTP requests */ + headers?: Record; + /** Bearer token for authentication */ + token?: string; +} + +/** + * Execution service - tracks tool/resource/prompt execution. + * + * Manages pending client requests (sampling, elicitation) during execution. + * Provides state subscription for UI updates. + */ +export interface ExecutionService { + /** + * Get current execution state. + */ + getState(): ExecutionState; + + /** + * Start a new execution. + * @param requestId Unique request ID for tracking + */ + startExecution(requestId: string): void; + + /** + * End the current execution. + */ + endExecution(): void; + + /** + * Cancel the current execution. + */ + cancelExecution(): void; + + /** + * Add a pending client request (sampling or elicitation). + */ + addPendingRequest(request: PendingClientRequest): void; + + /** + * Resolve a pending client request. + */ + resolvePendingRequest(id: string): void; + + /** + * Reject a pending client request. + */ + rejectPendingRequest(id: string): void; + + /** + * Clear all pending requests. + */ + clearPendingRequests(): void; + + /** + * Subscribe to state changes. + * Returns unsubscribe function. + */ + subscribe(listener: () => void): () => void; +} + +/** + * Execution state + */ +export interface ExecutionState { + /** Current executing request ID */ + currentRequestId: string | null; + /** Whether execution is in progress */ + isExecuting: boolean; + /** Pending client requests (sampling, elicitation) */ + pendingClientRequests: PendingClientRequest[]; +} + +/** + * Pending client request (sampling or elicitation) + */ +export interface PendingClientRequest { + /** Unique request ID */ + id: string; + /** Request type */ + type: 'sampling' | 'elicitation'; + /** The actual request data */ + request: SamplingRequest | ElicitationRequest; + /** Parent request ID (the tool call that triggered this) */ + parentRequestId: string; + /** Request status */ + status: 'pending' | 'resolved' | 'rejected'; + /** Timestamp when request was received */ + timestamp: string; +} + +/** + * Initial connection state + */ +export const initialConnectionState: ConnectionState = { + status: 'disconnected', + serverUrl: null, + serverInfo: null, + error: null, +}; + +/** + * Initial execution state + */ +export const initialExecutionState: ExecutionState = { + currentRequestId: null, + isExecuting: false, + pendingClientRequests: [], +}; diff --git a/core/src/handlers.ts b/core/src/handlers.ts new file mode 100644 index 000000000..2813067e5 --- /dev/null +++ b/core/src/handlers.ts @@ -0,0 +1,442 @@ +/** + * MCP Request Handlers + * + * Handles client-side requests from MCP servers: + * - Sampling: Server requests LLM completion from client + * - Elicitation: Server requests user input (form or URL) + * + * These handlers integrate with ExecutionContext to show UI + * and return user responses to the server. + */ + +import type { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { + CreateMessageRequestSchema, + ElicitRequestSchema, + type CreateMessageRequest, + type CreateMessageResult, + type ElicitRequest, + type ElicitResult, +} from '@modelcontextprotocol/sdk/types.js'; +import type { SamplingRequest, ElicitationRequest } from './types/clientRequests.js'; +import type { SamplingResponse } from './types/responses.js'; + +// Response resolvers - keyed by request ID +const samplingResolvers = new Map< + string, + { + resolve: (response: SamplingResponse) => void; + reject: (reason?: unknown) => void; + } +>(); + +const elicitationResolvers = new Map< + string, + { + resolve: (data: Record) => void; + reject: (reason?: unknown) => void; + } +>(); + +// Request ID counter for generating unique IDs +let requestIdCounter = 0; + +/** + * Generate a unique request ID for pending requests. + */ +export function generateSamplingRequestId(): string { + return `sampling-${Date.now()}-${++requestIdCounter}`; +} + +/** + * Generate a unique request ID for elicitation requests. + */ +export function generateElicitationRequestId(): string { + return `elicitation-${Date.now()}-${++requestIdCounter}`; +} + +/** + * Convert a single SDK content item to our internal format. + */ +function convertContentItem( + content: unknown +): { type: 'text'; text: string } | { type: 'image'; data: string; mimeType: string } { + // Handle content object with type field + const contentObj = content as { type?: string; text?: string; data?: string; mimeType?: string }; + + if (contentObj.type === 'text' && typeof contentObj.text === 'string') { + return { type: 'text' as const, text: contentObj.text }; + } + + if (contentObj.type === 'image' && typeof contentObj.data === 'string' && typeof contentObj.mimeType === 'string') { + return { type: 'image' as const, data: contentObj.data, mimeType: contentObj.mimeType }; + } + + // Fallback for unsupported types + return { type: 'text' as const, text: '[Unsupported content type]' }; +} + +/** + * Convert MCP SDK CreateMessageRequest to our internal SamplingRequest format. + */ +function convertToSamplingRequest( + sdkRequest: CreateMessageRequest['params'] +): SamplingRequest { + return { + messages: sdkRequest.messages.map((msg) => { + // Content can be a single item or an array + const contentItem = Array.isArray(msg.content) + ? msg.content[0] // Take first item if array + : msg.content; + + return { + role: msg.role as 'user' | 'assistant', + content: convertContentItem(contentItem), + }; + }), + modelPreferences: sdkRequest.modelPreferences + ? { + hints: sdkRequest.modelPreferences.hints + ?.map((h) => h.name) + .filter((name): name is string => name !== undefined), + costPriority: sdkRequest.modelPreferences.costPriority, + speedPriority: sdkRequest.modelPreferences.speedPriority, + intelligencePriority: sdkRequest.modelPreferences.intelligencePriority, + } + : undefined, + maxTokens: sdkRequest.maxTokens, + stopSequences: sdkRequest.stopSequences, + temperature: sdkRequest.temperature, + includeContext: sdkRequest.includeContext as + | 'none' + | 'thisServer' + | 'allServers' + | undefined, + }; +} + +/** + * Convert our SamplingResponse to MCP SDK CreateMessageResult format. + */ +function convertToSdkResult(response: SamplingResponse): CreateMessageResult { + // Handle both text and image content types + const content = response.content.type === 'text' + ? { type: 'text' as const, text: response.content.text } + : { type: 'image' as const, data: response.content.data, mimeType: response.content.mimeType }; + + return { + role: 'assistant', + content, + model: response.model, + stopReason: response.stopReason, + }; +} + +/** + * Callbacks for when sampling/elicitation requests are received. + */ +export interface SamplingHandlerCallbacks { + /** + * Called when a sampling request is received from the server. + * The implementation should show UI and eventually call resolveSamplingRequest. + */ + onSamplingRequest: ( + requestId: string, + request: SamplingRequest, + parentRequestId: string + ) => void; + + /** + * Called when a sampling request is cancelled by the server. + */ + onSamplingCancelled?: (requestId: string) => void; +} + +export interface ElicitationHandlerCallbacks { + /** + * Called when an elicitation request is received from the server. + * The implementation should show UI and eventually call resolveElicitationRequest. + */ + onElicitationRequest: ( + requestId: string, + request: ElicitationRequest, + parentRequestId: string + ) => void; + + /** + * Called when an elicitation request is cancelled by the server. + */ + onElicitationCancelled?: (requestId: string) => void; +} + +/** + * Set up the sampling request handler on the MCP client. + * + * @param client - The MCP client instance + * @param callbacks - Callbacks for handling requests + * @param parentRequestId - The ID of the parent tool execution request + */ +export function setupSamplingHandler( + client: Client, + callbacks: SamplingHandlerCallbacks, + parentRequestId: string +): void { + client.setRequestHandler( + CreateMessageRequestSchema, + async (request: CreateMessageRequest) => { + const requestId = generateSamplingRequestId(); + const samplingRequest = convertToSamplingRequest(request.params); + + console.log('[MCP Handlers] Sampling request received:', requestId, samplingRequest); + + // Create a promise that will be resolved by the UI + const responsePromise = new Promise((resolve, reject) => { + samplingResolvers.set(requestId, { resolve, reject }); + }); + + // Notify the UI about the new request + callbacks.onSamplingRequest(requestId, samplingRequest, parentRequestId); + + try { + // Wait for the UI to respond + const response = await responsePromise; + console.log('[MCP Handlers] Sampling response:', requestId, response); + + // Convert to SDK format and return + return convertToSdkResult(response); + } finally { + // Clean up the resolver + samplingResolvers.delete(requestId); + } + } + ); +} + +/** + * Convert MCP SDK ElicitRequest to our internal ElicitationRequest format. + */ +function convertToElicitationRequest( + sdkRequest: ElicitRequest['params'] +): ElicitationRequest { + // Check if it's a form-based or URL-based elicitation + if ('requestedSchema' in sdkRequest) { + // Form-based elicitation + return { + mode: 'form', + message: sdkRequest.message, + schema: { + properties: Object.fromEntries( + Object.entries(sdkRequest.requestedSchema.properties || {}).map(([key, value]) => { + const prop = value as { type?: string; description?: string; enum?: string[]; default?: unknown }; + return [ + key, + { + name: key, + type: (prop.type || 'string') as 'string' | 'number' | 'boolean', + description: prop.description, + enum: prop.enum, + default: prop.default as string | number | boolean | undefined, + }, + ]; + }) + ), + required: sdkRequest.requestedSchema.required, + }, + serverName: 'MCP Server', + }; + } else { + // URL-based elicitation + const urlParams = sdkRequest as { message: string; url: string; elicitationId: string }; + return { + mode: 'url', + message: urlParams.message, + url: urlParams.url, + elicitationId: urlParams.elicitationId, + serverName: 'MCP Server', + }; + } +} + +/** + * Convert user response to MCP SDK ElicitResult format. + */ +function convertToElicitResult( + data: Record, + action: 'accept' | 'decline' | 'cancel' = 'accept' +): ElicitResult { + // Convert data to the expected type for ElicitResult.content + const content: Record | undefined = + action === 'accept' + ? Object.fromEntries( + Object.entries(data).map(([key, value]) => { + // Ensure values match expected types + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return [key, value]; + } + if (Array.isArray(value) && value.every((v) => typeof v === 'string')) { + return [key, value as string[]]; + } + // Convert other types to string + return [key, String(value)]; + }) + ) + : undefined; + + return { + action, + content, + }; +} + +/** + * Set up the elicitation request handler on the MCP client. + * + * @param client - The MCP client instance + * @param callbacks - Callbacks for handling requests + * @param parentRequestId - The ID of the parent tool execution request + */ +export function setupElicitationHandler( + client: Client, + callbacks: ElicitationHandlerCallbacks, + parentRequestId: string +): void { + client.setRequestHandler( + ElicitRequestSchema, + async (request: ElicitRequest) => { + const requestId = generateElicitationRequestId(); + const elicitationRequest = convertToElicitationRequest(request.params); + + console.log('[MCP Handlers] Elicitation request received:', requestId, elicitationRequest); + + // Create a promise that will be resolved by the UI + const responsePromise = new Promise>((resolve, reject) => { + elicitationResolvers.set(requestId, { resolve, reject }); + }); + + // Notify the UI about the new request + callbacks.onElicitationRequest(requestId, elicitationRequest, parentRequestId); + + try { + // Wait for the UI to respond + const response = await responsePromise; + console.log('[MCP Handlers] Elicitation response:', requestId, response); + + // Convert to SDK format and return + return convertToElicitResult(response); + } catch (err) { + // User declined or cancelled + const message = err instanceof Error ? err.message : 'Unknown error'; + if (message.includes('rejected') || message.includes('declined')) { + return convertToElicitResult({}, 'decline'); + } + return convertToElicitResult({}, 'cancel'); + } finally { + // Clean up the resolver + elicitationResolvers.delete(requestId); + } + } + ); +} + +/** + * Resolve a pending sampling request with a response. + * This should be called by the UI when the user provides a response. + * + * @param requestId - The ID of the sampling request + * @param response - The sampling response from the user + */ +export function resolveSamplingRequest( + requestId: string, + response: SamplingResponse +): void { + const resolver = samplingResolvers.get(requestId); + if (resolver) { + resolver.resolve(response); + samplingResolvers.delete(requestId); + } else { + console.warn('[MCP Handlers] No resolver found for sampling request:', requestId); + } +} + +/** + * Reject a pending sampling request. + * This should be called by the UI when the user rejects the request. + * + * @param requestId - The ID of the sampling request + * @param reason - Optional reason for rejection + */ +export function rejectSamplingRequest(requestId: string, reason?: string): void { + const resolver = samplingResolvers.get(requestId); + if (resolver) { + resolver.reject(new Error(reason || 'Sampling request rejected by user')); + samplingResolvers.delete(requestId); + } else { + console.warn('[MCP Handlers] No resolver found for sampling request:', requestId); + } +} + +/** + * Resolve a pending elicitation request with user input. + * + * @param requestId - The ID of the elicitation request + * @param data - The user input data + */ +export function resolveElicitationRequest( + requestId: string, + data: Record +): void { + const resolver = elicitationResolvers.get(requestId); + if (resolver) { + resolver.resolve(data); + elicitationResolvers.delete(requestId); + } else { + console.warn('[MCP Handlers] No resolver found for elicitation request:', requestId); + } +} + +/** + * Reject a pending elicitation request. + * + * @param requestId - The ID of the elicitation request + * @param reason - Optional reason for rejection + */ +export function rejectElicitationRequest(requestId: string, reason?: string): void { + const resolver = elicitationResolvers.get(requestId); + if (resolver) { + resolver.reject(new Error(reason || 'Elicitation request rejected by user')); + elicitationResolvers.delete(requestId); + } else { + console.warn('[MCP Handlers] No resolver found for elicitation request:', requestId); + } +} + +/** + * Clear all pending resolvers (e.g., when disconnecting). + */ +export function clearAllPendingRequests(): void { + // Reject all pending sampling requests + for (const [requestId, resolver] of samplingResolvers) { + resolver.reject(new Error('Connection closed')); + samplingResolvers.delete(requestId); + } + + // Reject all pending elicitation requests + for (const [requestId, resolver] of elicitationResolvers) { + resolver.reject(new Error('Connection closed')); + elicitationResolvers.delete(requestId); + } +} + +/** + * Check if there are any pending sampling requests. + */ +export function hasPendingSamplingRequests(): boolean { + return samplingResolvers.size > 0; +} + +/** + * Check if there are any pending elicitation requests. + */ +export function hasPendingElicitationRequests(): boolean { + return elicitationResolvers.size > 0; +} diff --git a/core/src/index.ts b/core/src/index.ts new file mode 100644 index 000000000..7278e48d6 --- /dev/null +++ b/core/src/index.ts @@ -0,0 +1,44 @@ +/** + * @anthropic/inspector-core + * + * Core MCP client logic for Inspector - no React dependencies. + * This package can be used in CLI tools, test frameworks, or any JS environment. + */ + +// Client lifecycle +export { + createMcpClient, + connectClient, + disconnectClient, + isClientConnected, + serverSupports, +} from './client.js'; +export type { McpClientOptions, ServerInfo } from './client.js'; + +// Transport creation +export { + createHttpTransport, + createAuthenticatedTransport, + isValidHttpUrl, +} from './transport.js'; + +// Request handlers (sampling, elicitation) +export { + setupSamplingHandler, + setupElicitationHandler, + resolveSamplingRequest, + rejectSamplingRequest, + resolveElicitationRequest, + rejectElicitationRequest, + generateSamplingRequestId, + generateElicitationRequestId, + clearAllPendingRequests, + hasPendingSamplingRequests, + hasPendingElicitationRequests, +} from './handlers.js'; + +// All types +export * from './types/index.js'; + +// Data layer interfaces and memory stubs +export * from './data/index.js'; diff --git a/core/src/transport.ts b/core/src/transport.ts new file mode 100644 index 000000000..35df70410 --- /dev/null +++ b/core/src/transport.ts @@ -0,0 +1,65 @@ +/** + * HTTP Transport Factory - Create transports for remote MCP servers + * + * This module provides factory functions for creating HTTP transports + * that work in the browser using the MCP SDK's StreamableHTTPClientTransport. + * + * Usage: + * import { createHttpTransport } from '@/lib/mcp/transport'; + * + * const transport = createHttpTransport('http://localhost:3000/mcp'); + * await client.connect(transport); + */ + +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; + +/** + * Create an HTTP transport for a remote MCP server. + * + * @param url - The MCP server endpoint URL + * @param headers - Optional custom headers (e.g., for API keys) + * @returns StreamableHTTPClientTransport instance + */ +export function createHttpTransport( + url: string, + headers?: Record +): StreamableHTTPClientTransport { + const options: ConstructorParameters[1] = {}; + + if (headers) { + options.requestInit = { headers }; + } + + return new StreamableHTTPClientTransport(new URL(url), options); +} + +/** + * Create an HTTP transport with Bearer token authentication. + * + * @param url - The MCP server endpoint URL + * @param token - Bearer token (with or without 'Bearer ' prefix) + * @returns StreamableHTTPClientTransport instance + */ +export function createAuthenticatedTransport( + url: string, + token: string +): StreamableHTTPClientTransport { + return createHttpTransport(url, { + Authorization: token.startsWith('Bearer ') ? token : `Bearer ${token}`, + }); +} + +/** + * Validate that a URL is suitable for HTTP transport. + * + * @param url - URL to validate + * @returns true if valid HTTP/HTTPS URL + */ +export function isValidHttpUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === 'http:' || parsed.protocol === 'https:'; + } catch { + return false; + } +} diff --git a/core/src/types/auth.ts b/core/src/types/auth.ts new file mode 100644 index 000000000..942d995ec --- /dev/null +++ b/core/src/types/auth.ts @@ -0,0 +1,29 @@ +/** + * Authentication and OAuth types + */ + +export interface OAuthState { + authorizationUrl?: string; + authorizationCode?: string; + state?: string; + stateVerified?: boolean; + tokenEndpoint?: string; + accessToken?: string; + tokenType?: string; + expiresIn?: number; + expiresAt?: Date; + refreshToken?: string; + scopes?: string[]; + decodedToken?: { + header: Record; + payload: Record; + }; +} + +/** + * Root directory configuration for MCP roots capability + */ +export interface Root { + name: string; + uri: string; +} diff --git a/core/src/types/capabilities.ts b/core/src/types/capabilities.ts new file mode 100644 index 000000000..1dceeea85 --- /dev/null +++ b/core/src/types/capabilities.ts @@ -0,0 +1,11 @@ +/** + * Experimental capabilities configuration types + */ + +export interface ExperimentalCapability { + id: string; + name: string; + description: string; + enabled: boolean; + warning?: string; +} diff --git a/core/src/types/clientRequests.ts b/core/src/types/clientRequests.ts new file mode 100644 index 000000000..588d7d6f5 --- /dev/null +++ b/core/src/types/clientRequests.ts @@ -0,0 +1,68 @@ +/** + * Client request types for MCP client features + * Per MCP 2025-11-25 specification + */ + +import type { ToolDefinition, ToolChoice } from './responses'; + +// Sampling message content +export interface SamplingMessage { + role: 'user' | 'assistant'; + content: + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string }; +} + +// Model preferences for sampling +export interface ModelPreferences { + hints?: string[]; + costPriority?: number; + speedPriority?: number; + intelligencePriority?: number; +} + +// Sampling request from server +export interface SamplingRequest { + messages: SamplingMessage[]; + modelPreferences?: ModelPreferences; + maxTokens: number; + stopSequences?: string[]; + temperature?: number; + includeContext?: 'none' | 'thisServer' | 'allServers'; + // Tool calling support (MCP 2025-11-25) + tools?: ToolDefinition[]; + toolChoice?: ToolChoice; +} + +// Elicitation form field schema +export interface ElicitationFormField { + name: string; + type: 'string' | 'number' | 'boolean'; + description?: string; + required?: boolean; + enum?: string[]; + default?: string | number | boolean; +} + +// Elicitation form request +export interface ElicitationFormRequest { + mode: 'form'; + message: string; + schema: { + properties: Record; + required?: string[]; + }; + serverName: string; +} + +// Elicitation URL request +export interface ElicitationUrlRequest { + mode: 'url'; + message: string; + url: string; + elicitationId: string; + serverName: string; +} + +// Union type for elicitation requests +export type ElicitationRequest = ElicitationFormRequest | ElicitationUrlRequest; diff --git a/core/src/types/history.ts b/core/src/types/history.ts new file mode 100644 index 000000000..1fdbdeeae --- /dev/null +++ b/core/src/types/history.ts @@ -0,0 +1,73 @@ +/** + * History types for MCP request tracking + * + * These types support hierarchical request traces where tool calls + * can trigger child requests (sampling, elicitation). + */ + +/** + * Request type discriminator for hierarchical display + * - 'primary': Tool calls, resource reads, prompt gets + * - 'client': Sampling/elicitation requests triggered by server + */ +export type RequestType = 'primary' | 'client'; + +/** + * A single entry in the request history + */ +export interface HistoryEntry { + /** Unique identifier */ + id: string; + /** ISO timestamp of when the request was made */ + timestamp: string; + /** MCP method (e.g., 'tools/call', 'resources/read', 'sampling/createMessage') */ + method: string; + /** Target name (tool name, resource URI, prompt name) */ + target: string | null; + /** Request parameters */ + params?: Record; + /** Response data */ + response?: Record; + /** Request duration in milliseconds */ + duration: number; + /** Whether the request succeeded */ + success: boolean; + /** Whether this entry is pinned (protected from cleanup) */ + pinned: boolean; + /** Optional user-provided label */ + label?: string; + /** SSE event ID (for SSE transport) */ + sseId?: string; + /** Progress token for long-running operations */ + progressToken?: string; + + // Hierarchical request trace fields + + /** Request type: 'primary' for tool/resource/prompt calls, 'client' for sampling/elicitation */ + requestType?: RequestType; + /** For client requests, links to parent tool call */ + parentRequestId?: string; + /** For primary requests, links to triggered client requests */ + childRequestIds?: string[]; + /** Offset from parent request start (ms), for client requests */ + relativeTime?: number; +} + +/** + * Create a new history entry with defaults + */ +export function createHistoryEntry( + partial: Omit & { id?: string } +): HistoryEntry { + return { + id: partial.id || `req-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + timestamp: new Date().toISOString(), + pinned: false, + ...partial, + }; +} + +/** + * Maximum number of history entries to keep (excluding pinned) + */ +export const MAX_HISTORY_ENTRIES = 500; diff --git a/core/src/types/index.ts b/core/src/types/index.ts new file mode 100644 index 000000000..e4b92ec79 --- /dev/null +++ b/core/src/types/index.ts @@ -0,0 +1,12 @@ +/** + * Re-export all types for @anthropic/inspector-core + */ +export * from './history.js'; +export * from './logs.js'; +export * from './servers.js'; +export * from './clientRequests.js'; +export * from './responses.js'; +export * from './testingProfiles.js'; +export * from './capabilities.js'; +export * from './tasks.js'; +export * from './auth.js'; diff --git a/core/src/types/logs.ts b/core/src/types/logs.ts new file mode 100644 index 000000000..7a8073cf4 --- /dev/null +++ b/core/src/types/logs.ts @@ -0,0 +1,84 @@ +/** + * Log types for MCP protocol event logging + * + * Supports RFC 5424 log levels and request correlation + * for filtering logs by request chain. + */ + +/** + * RFC 5424 log levels in order of severity + */ +export const LOG_LEVELS = [ + 'debug', + 'info', + 'notice', + 'warning', + 'error', + 'critical', + 'alert', + 'emergency', +] as const; + +export type LogLevel = (typeof LOG_LEVELS)[number]; + +/** + * Color mapping for log levels (Mantine color names) + */ +export const LOG_LEVEL_COLORS: Record = { + debug: 'gray', + info: 'blue', + notice: 'cyan', + warning: 'yellow', + error: 'red', + critical: 'red', + alert: 'red', + emergency: 'red', +}; + +/** + * Logger categories for grouping logs + */ +export type LoggerCategory = 'connection' | 'protocol' | 'tools' | 'resources' | 'prompts' | 'sampling' | 'elicitation'; + +/** + * A single log entry + */ +export interface LogEntry { + /** ISO timestamp of when the log was created */ + timestamp: string; + /** Log level (RFC 5424) */ + level: LogLevel; + /** Log message */ + message: string; + /** Logger category */ + logger: string; + /** The request ID that generated this log (for correlation) */ + requestId?: string; + /** For logs from client requests, links to parent request */ + parentRequestId?: string; +} + +/** + * Create a new log entry with automatic timestamp + */ +export function createLogEntry( + level: LogLevel, + message: string, + logger: string, + requestId?: string, + parentRequestId?: string +): LogEntry { + return { + timestamp: new Date().toISOString(), + level, + message, + logger, + requestId, + parentRequestId, + }; +} + +/** + * Maximum number of log entries to keep + */ +export const MAX_LOG_ENTRIES = 1000; diff --git a/core/src/types/responses.ts b/core/src/types/responses.ts new file mode 100644 index 000000000..4927515fc --- /dev/null +++ b/core/src/types/responses.ts @@ -0,0 +1,58 @@ +/** + * Shared response types for MCP client features + * Per MCP 2025-11-25 specification + */ + +// Tool definition for sampling requests +export interface ToolDefinition { + name: string; + description?: string; + inputSchema?: Record; +} + +// Tool call in sampling response +export interface ToolCall { + id: string; + name: string; + arguments: Record; +} + +// Tool choice options +export type ToolChoice = + | { type: 'auto' } + | { type: 'none' } + | { type: 'required' } + | { type: 'tool'; name: string }; + +// Sampling response content +export type SamplingContent = + | { type: 'text'; text: string } + | { type: 'image'; data: string; mimeType: string }; + +// Stop reason for sampling response +export type StopReason = 'endTurn' | 'stopSequence' | 'maxTokens' | 'toolUse'; + +// Sampling response sent back to server +export interface SamplingResponse { + content: SamplingContent; + model: string; + stopReason: StopReason; + toolCalls?: ToolCall[]; +} + +// Elicitation response action +export type ElicitationAction = 'accept' | 'decline' | 'cancel'; + +// Elicitation response sent back to server +export interface ElicitationResponse { + action: ElicitationAction; + data?: Record; +} + +// Request info for logs correlation +export interface RequestInfo { + id: string; + method: string; + target?: string; + timestamp: string; +} diff --git a/core/src/types/servers.ts b/core/src/types/servers.ts new file mode 100644 index 000000000..4952f1df3 --- /dev/null +++ b/core/src/types/servers.ts @@ -0,0 +1,109 @@ +/** + * Server configuration types for MCP Inspector + * + * ServerConfig represents the persisted configuration for an MCP server. + * This is separate from runtime state (connection status, capabilities). + */ + +/** + * Transport type for MCP servers + */ +export type TransportType = 'http' | 'stdio'; + +/** + * Connection mode for servers + * - 'direct': Connect directly from browser (HTTP only) + * - 'proxy': Connect via proxy server (required for STDIO) + */ +export type ConnectionMode = 'direct' | 'proxy'; + +/** + * Server configuration (persisted) + */ +export interface ServerConfig { + /** Unique identifier */ + id: string; + /** Display name */ + name: string; + /** Transport type */ + transport: TransportType; + /** URL for HTTP transport */ + url?: string; + /** Command for STDIO transport (future, requires proxy) */ + command?: string; + /** Arguments for STDIO command */ + args?: string[]; + /** Environment variables for STDIO */ + env?: Record; + /** Custom headers for HTTP requests */ + headers?: Record; + /** Connection mode */ + connectionMode?: ConnectionMode; + /** ISO timestamp of creation */ + createdAt: string; + /** ISO timestamp of last update */ + updatedAt: string; +} + +/** + * Runtime server state (not persisted) + */ +export interface ServerRuntimeState { + /** Connection status */ + status: 'disconnected' | 'connecting' | 'connected' | 'failed'; + /** Capabilities once connected */ + capabilities?: { + tools?: number; + resources?: number; + prompts?: number; + }; + /** Error message if failed */ + error?: string; + /** Retry count for failed connections */ + retryCount?: number; +} + +/** + * Combined server info (config + runtime state) + */ +export interface ServerInfo extends ServerConfig { + /** Runtime state */ + runtime: ServerRuntimeState; +} + +/** + * Create a new server config with defaults + */ +export function createServerConfig( + partial: Omit & { id?: string } +): ServerConfig { + const now = new Date().toISOString(); + return { + id: partial.id || `server-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`, + createdAt: now, + updatedAt: now, + connectionMode: partial.transport === 'http' ? 'direct' : 'proxy', + ...partial, + }; +} + +/** + * Server display model for UI components (ServerCard) + * Combines config with runtime state for display purposes + */ +export interface ServerDisplayModel { + id: string; + name: string; + version: string; + transport: 'stdio' | 'http'; + command?: string; + url?: string; + status: 'connected' | 'disconnected' | 'failed'; + capabilities: { tools: number; resources: number; prompts: number } | null; + retryCount?: number; + error?: string; + connectionMode?: ConnectionMode; +} + +/** @deprecated Use ServerDisplayModel instead */ +export type MockServer = ServerDisplayModel; diff --git a/core/src/types/tasks.ts b/core/src/types/tasks.ts new file mode 100644 index 000000000..6e5ed0890 --- /dev/null +++ b/core/src/types/tasks.ts @@ -0,0 +1,32 @@ +/** + * Task tracking types for long-running operations + */ + +export interface ActiveTask { + id: string; + method: string; + name: string; + status: 'running' | 'waiting'; + progress: number; + progressMessage: string | null; + startedAt: string; +} + +export interface CompletedTask { + id: string; + method: string; + name: string; + status: 'completed' | 'failed'; + progress: number; + startedAt: string; + completedAt: string; + error?: string; +} + +export const taskStatusColors: Record = { + waiting: 'gray', + running: 'blue', + completed: 'green', + failed: 'red', + cancelled: 'orange', +}; diff --git a/core/src/types/testingProfiles.ts b/core/src/types/testingProfiles.ts new file mode 100644 index 000000000..45128aef7 --- /dev/null +++ b/core/src/types/testingProfiles.ts @@ -0,0 +1,48 @@ +/** + * Testing Profiles for Sampling/Elicitation Response Strategies + */ + +export type SamplingProviderType = 'manual' | 'mock'; + +export interface ModelOverride { + pattern: string; // e.g., "claude-*", "gpt-*" + response: string; +} + +export interface TestingProfile { + id: string; + name: string; + description?: string; + samplingProvider: SamplingProviderType; + autoRespond: boolean; + defaultResponse?: string; + defaultModel?: string; + defaultStopReason?: 'endTurn' | 'stopSequence' | 'maxTokens' | 'toolUse'; + modelOverrides?: ModelOverride[]; + elicitationAutoRespond?: boolean; + elicitationDefaults?: Record; +} + +/** + * Helper to get response for a model hint based on profile overrides + */ +export function getResponseForModelHint( + profile: TestingProfile, + modelHints?: string[] +): string { + if (!profile.modelOverrides || !modelHints || modelHints.length === 0) { + return profile.defaultResponse || ''; + } + + // Check each hint against patterns + for (const hint of modelHints) { + for (const override of profile.modelOverrides) { + const pattern = override.pattern.replace('*', '.*'); + if (new RegExp(`^${pattern}$`).test(hint)) { + return override.response; + } + } + } + + return profile.defaultResponse || ''; +} diff --git a/core/test-core.js b/core/test-core.js new file mode 100644 index 000000000..7bc674dbf --- /dev/null +++ b/core/test-core.js @@ -0,0 +1,402 @@ +/** + * Manual test script for inspector-core package + * Run with: node test-core.mjs + */ + +import { + // Memory repositories + createMemoryServerConfigRepository, + createMemoryHistoryRepository, + createMemoryLogsRepository, + createMemoryTestingProfileRepository, + + // Memory services + createMemoryConnectionService, + createMemoryExecutionService, + + // Utilities + isValidHttpUrl, + generateSamplingRequestId, + generateElicitationRequestId, + + // Client (can create, but not connect without server) + createMcpClient, + + // Types/helpers + createServerConfig, + createHistoryEntry, + createLogEntry, + LOG_LEVELS, +} from './dist/index.js'; + +console.log('=== Inspector Core Package Tests ===\n'); + +// Track test results +let passed = 0; +let failed = 0; + +function test(name, fn) { + try { + fn(); + console.log(`[PASS] ${name}`); + passed++; + } catch (error) { + console.log(`[FAIL] ${name}`); + console.log(` ${error.message}`); + failed++; + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message || 'Assertion failed'); +} + +// ============================================ +// 1. URL Validation +// ============================================ +console.log('\n--- URL Validation ---'); + +test('isValidHttpUrl accepts http://localhost:3000/mcp', () => { + assert(isValidHttpUrl('http://localhost:3000/mcp') === true); +}); + +test('isValidHttpUrl accepts https://api.example.com/mcp', () => { + assert(isValidHttpUrl('https://api.example.com/mcp') === true); +}); + +test('isValidHttpUrl rejects ftp://example.com', () => { + assert(isValidHttpUrl('ftp://example.com') === false); +}); + +test('isValidHttpUrl rejects invalid URL', () => { + assert(isValidHttpUrl('not-a-url') === false); +}); + +// ============================================ +// 2. ID Generation +// ============================================ +console.log('\n--- ID Generation ---'); + +test('generateSamplingRequestId returns unique IDs', () => { + const id1 = generateSamplingRequestId(); + const id2 = generateSamplingRequestId(); + assert(id1 !== id2, 'IDs should be unique'); + assert(id1.startsWith('sampling-'), 'Should start with sampling-'); +}); + +test('generateElicitationRequestId returns unique IDs', () => { + const id1 = generateElicitationRequestId(); + const id2 = generateElicitationRequestId(); + assert(id1 !== id2, 'IDs should be unique'); + assert(id1.startsWith('elicitation-'), 'Should start with elicitation-'); +}); + +// ============================================ +// 3. ServerConfig Repository +// ============================================ +console.log('\n--- ServerConfig Repository ---'); + +test('ServerConfigRepository CRUD operations', async () => { + const repo = createMemoryServerConfigRepository(); + + // Create + const config = await repo.create({ + name: 'Test Server', + transport: 'http', + url: 'http://localhost:3000/mcp', + connectionMode: 'direct', + }); + assert(config.id, 'Should have generated ID'); + assert(config.name === 'Test Server', 'Name should match'); + assert(config.createdAt, 'Should have createdAt'); + + // List + const list = await repo.list(); + assert(list.length === 1, 'Should have 1 config'); + + // Get + const fetched = await repo.get(config.id); + assert(fetched?.name === 'Test Server', 'Get should return config'); + + // Update + const updated = await repo.update(config.id, { name: 'Updated Server' }); + assert(updated.name === 'Updated Server', 'Name should be updated'); + assert(updated.updatedAt, 'Should have updatedAt'); + + // Delete + await repo.delete(config.id); + const afterDelete = await repo.list(); + assert(afterDelete.length === 0, 'Should be empty after delete'); +}); + +// ============================================ +// 4. History Repository +// ============================================ +console.log('\n--- History Repository ---'); + +test('HistoryRepository with parent-child relationships', async () => { + const repo = createMemoryHistoryRepository(); + + // Create parent entry (tool call) + const parent = await repo.add({ + method: 'tools/call', + target: 'test_tool', + params: { arg: 'value' }, + response: { result: 'success' }, + duration: 100, + success: true, + requestType: 'primary', + }); + assert(parent.id, 'Parent should have ID'); + + // Create child entry (sampling request) + const child = await repo.add({ + method: 'sampling/createMessage', + target: 'claude-3-sonnet', + params: { messages: [] }, + response: { content: 'response' }, + duration: 50, + success: true, + requestType: 'client', + parentRequestId: parent.id, + relativeTime: 25, + }); + + // Get children + const children = await repo.getChildren(parent.id); + assert(children.length === 1, 'Should have 1 child'); + assert(children[0].parentRequestId === parent.id, 'Child should reference parent'); + + // List with rootOnly + const rootOnly = await repo.list({ rootOnly: true }); + assert(rootOnly.length === 1, 'Should only return parent'); + assert(rootOnly[0].id === parent.id, 'Should be the parent'); + + // Delete all but keep pinned + await repo.update(parent.id, { pinned: true }); + await repo.deleteAll({ keepPinned: true }); + const afterDelete = await repo.list(); + assert(afterDelete.length === 1, 'Pinned entry should remain'); +}); + +// ============================================ +// 5. Logs Repository +// ============================================ +console.log('\n--- Logs Repository ---'); + +test('LogsRepository with request correlation', async () => { + const repo = createMemoryLogsRepository(); + + const requestId = 'req-123'; + + // Add logs for a request + await repo.add({ + level: 'info', + message: 'Starting request', + logger: 'connection', + requestId, + }); + + await repo.add({ + level: 'debug', + message: 'Processing data', + logger: 'tools', + requestId, + }); + + await repo.add({ + level: 'info', + message: 'Unrelated log', + logger: 'connection', + }); + + // Get logs for request + const requestLogs = await repo.getForRequest(requestId); + assert(requestLogs.length === 2, 'Should have 2 logs for request'); + + // Filter by level + const debugOnly = await repo.list({ minLevel: 'debug' }); + assert(debugOnly.every(l => LOG_LEVELS.indexOf(l.level) >= LOG_LEVELS.indexOf('debug'))); + + // Batch add + const batch = await repo.addBatch([ + { level: 'warning', message: 'Warning 1', logger: 'test' }, + { level: 'error', message: 'Error 1', logger: 'test' }, + ]); + assert(batch.length === 2, 'Batch should add 2 logs'); +}); + +// ============================================ +// 6. Testing Profile Repository +// ============================================ +console.log('\n--- Testing Profile Repository ---'); + +test('TestingProfileRepository operations', async () => { + const repo = createMemoryTestingProfileRepository(); + + // Create profile + const profile = await repo.create({ + name: 'Auto Mock', + description: 'Automatic mock responses', + samplingProvider: 'mock', + autoRespond: true, + defaultResponse: 'Mock response', + defaultModel: 'mock-model', + defaultStopReason: 'endTurn', + modelOverrides: [ + { pattern: 'claude-*', response: 'Claude mock response' }, + ], + }); + assert(profile.id, 'Should have ID'); + + // List + const list = await repo.list(); + assert(list.length === 1, 'Should have 1 profile'); + + // Update + await repo.update(profile.id, { autoRespond: false }); + const updated = await repo.get(profile.id); + assert(updated?.autoRespond === false, 'autoRespond should be updated'); +}); + +// ============================================ +// 7. Connection Service +// ============================================ +console.log('\n--- Connection Service ---'); + +test('ConnectionService state management', async () => { + const service = createMemoryConnectionService(); + + // Initial state + let state = service.getState(); + assert(state.status === 'disconnected', 'Initial status should be disconnected'); + assert(state.serverUrl === null, 'No server URL initially'); + + // Subscribe to changes + let notified = false; + const unsubscribe = service.subscribe(() => { + notified = true; + }); + + // Note: connect() would fail without real server, but we can test disconnect + await service.disconnect(); + state = service.getState(); + assert(state.status === 'disconnected', 'Should stay disconnected'); + + unsubscribe(); +}); + +// ============================================ +// 8. Execution Service +// ============================================ +console.log('\n--- Execution Service ---'); + +test('ExecutionService pending requests', () => { + const service = createMemoryExecutionService(); + + // Initial state + let state = service.getState(); + assert(state.isExecuting === false, 'Not executing initially'); + assert(state.pendingClientRequests.length === 0, 'No pending requests'); + + // Start execution + service.startExecution('req-001'); + state = service.getState(); + assert(state.isExecuting === true, 'Should be executing'); + assert(state.currentRequestId === 'req-001', 'Should have request ID'); + + // Add pending request + service.addPendingRequest({ + id: 'sampling-001', + type: 'sampling', + request: { + messages: [{ role: 'user', content: { type: 'text', text: 'Hello' } }], + }, + parentRequestId: 'req-001', + status: 'pending', + timestamp: new Date().toISOString(), + }); + state = service.getState(); + assert(state.pendingClientRequests.length === 1, 'Should have 1 pending request'); + + // Resolve pending request + service.resolvePendingRequest('sampling-001'); + state = service.getState(); + assert(state.pendingClientRequests[0].status === 'resolved', 'Should be resolved'); + + // End execution (doesn't clear pending requests - that's separate) + service.endExecution(); + state = service.getState(); + assert(state.isExecuting === false, 'Should not be executing'); + assert(state.currentRequestId === null, 'Request ID should be null'); + + // Clear pending requests explicitly + service.clearPendingRequests(); + state = service.getState(); + assert(state.pendingClientRequests.length === 0, 'Pending requests cleared after clearPendingRequests()'); +}); + +// ============================================ +// 9. MCP Client Creation +// ============================================ +console.log('\n--- MCP Client Creation ---'); + +test('createMcpClient returns client instance', () => { + const client = createMcpClient({ + name: 'Test Inspector', + version: '1.0.0', + }); + assert(client, 'Should return client instance'); + // Can't test connect without actual server +}); + +// ============================================ +// 10. Type Helpers +// ============================================ +console.log('\n--- Type Helpers ---'); + +test('createServerConfig helper', () => { + const config = createServerConfig({ + name: 'My Server', + transport: 'http', + url: 'http://localhost:3000/mcp', + }); + assert(config.connectionMode === 'direct', 'Default connectionMode should be direct'); +}); + +test('createHistoryEntry helper', () => { + const entry = createHistoryEntry({ + method: 'tools/call', + target: 'echo', + success: true, + duration: 100, + }); + assert(entry.id, 'Should generate ID'); + assert(entry.timestamp, 'Should generate timestamp'); + assert(entry.pinned === false, 'Default pinned should be false'); + assert(entry.method === 'tools/call', 'Method should match'); +}); + +test('createLogEntry helper', () => { + const entry = createLogEntry({ + level: 'info', + message: 'Test log', + }); + assert(entry.timestamp, 'Should have timestamp'); +}); + +test('LOG_LEVELS array has 8 RFC 5424 levels', () => { + assert(LOG_LEVELS.length === 8, 'Should have 8 levels'); + assert(LOG_LEVELS.includes('debug'), 'Should include debug'); + assert(LOG_LEVELS.includes('emergency'), 'Should include emergency'); +}); + +// ============================================ +// Summary +// ============================================ +console.log('\n=== Test Summary ==='); +console.log(`Passed: ${passed}`); +console.log(`Failed: ${failed}`); +console.log(`Total: ${passed + failed}`); + +process.exit(failed > 0 ? 1 : 0); diff --git a/core/test-real-server.js b/core/test-real-server.js new file mode 100644 index 000000000..46a74b89d --- /dev/null +++ b/core/test-real-server.js @@ -0,0 +1,180 @@ +/** + * Integration test with real Everything MCP server + * + * Prerequisites: + * PORT=6299 npx -y @modelcontextprotocol/server-everything streamableHttp + * + * Run with: + * node test-real-server.js + */ + +import { + createMcpClient, + connectClient, + disconnectClient, + isClientConnected, + serverSupports, + createHttpTransport, +} from './dist/index.js'; + +const SERVER_URL = 'http://localhost:6299/mcp'; + +console.log('=== Real MCP Server Integration Tests ===\n'); +console.log(`Connecting to: ${SERVER_URL}\n`); + +let passed = 0; +let failed = 0; + +async function test(name, fn) { + try { + await fn(); + console.log(`[PASS] ${name}`); + passed++; + } catch (error) { + console.log(`[FAIL] ${name}`); + console.log(` ${error.message}`); + failed++; + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message || 'Assertion failed'); +} + +// ============================================ +// Run Tests +// ============================================ + +async function runTests() { + let client; + let serverInfo; + + // Test 1: Create client and connect + await test('Connect to Everything server', async () => { + client = createMcpClient({ name: 'Test Client', version: '1.0.0' }); + const transport = createHttpTransport(SERVER_URL); + serverInfo = await connectClient(client, transport); + + assert(serverInfo, 'Should return server info'); + assert(serverInfo.name, 'Server should have name'); + assert(serverInfo.version, 'Server should have version'); + console.log(` Server: ${serverInfo.name} v${serverInfo.version}`); + }); + + // Test 2: Check connection status + await test('Client reports connected', async () => { + assert(isClientConnected(client) === true, 'Should be connected'); + }); + + // Test 3: Check server capabilities + await test('Server has tools capability', async () => { + assert(serverSupports(serverInfo, 'tools') === true, 'Should support tools'); + }); + + await test('Server has resources capability', async () => { + assert(serverSupports(serverInfo, 'resources') === true, 'Should support resources'); + }); + + await test('Server has prompts capability', async () => { + assert(serverSupports(serverInfo, 'prompts') === true, 'Should support prompts'); + }); + + // Test 4: List tools + await test('List tools from server', async () => { + const result = await client.listTools(); + assert(result.tools, 'Should return tools array'); + assert(result.tools.length > 0, 'Should have at least one tool'); + console.log(` Found ${result.tools.length} tools: ${result.tools.map(t => t.name).join(', ')}`); + }); + + // Test 5: List resources + await test('List resources from server', async () => { + const result = await client.listResources(); + assert(result.resources, 'Should return resources array'); + console.log(` Found ${result.resources.length} resources`); + }); + + // Test 6: List prompts + await test('List prompts from server', async () => { + const result = await client.listPrompts(); + assert(result.prompts, 'Should return prompts array'); + console.log(` Found ${result.prompts.length} prompts`); + }); + + // Test 7: Call echo tool + await test('Call echo tool', async () => { + const result = await client.callTool({ + name: 'echo', + arguments: { message: 'Hello from inspector-core!' }, + }); + assert(result.content, 'Should return content'); + assert(result.content.length > 0, 'Should have content items'); + const text = result.content[0].text; + assert(text.includes('Hello from inspector-core'), 'Should echo our message'); + console.log(` Response: ${text}`); + }); + + // Test 8: Call add tool + await test('Call add tool', async () => { + const result = await client.callTool({ + name: 'add', + arguments: { a: 5, b: 3 }, + }); + assert(result.content, 'Should return content'); + const text = result.content[0].text; + assert(text.includes('8'), 'Should return 8'); + console.log(` 5 + 3 = ${text}`); + }); + + // Test 9: Read a resource + await test('Read a resource', async () => { + const resources = await client.listResources(); + if (resources.resources.length > 0) { + const uri = resources.resources[0].uri; + const result = await client.readResource({ uri }); + assert(result.contents, 'Should return contents'); + console.log(` Read resource: ${uri}`); + } + }); + + // Test 10: Get a prompt + await test('Get a prompt', async () => { + const prompts = await client.listPrompts(); + if (prompts.prompts.length > 0) { + const name = prompts.prompts[0].name; + const result = await client.getPrompt({ name }); + assert(result.messages, 'Should return messages'); + console.log(` Got prompt: ${name} (${result.messages.length} messages)`); + } + }); + + // Test 11: Disconnect + await test('Disconnect from server', async () => { + await disconnectClient(client); + // Note: isClientConnected may still return true due to cached server version + // The important thing is disconnect completes without error + }); + + // ============================================ + // Summary + // ============================================ + console.log('\n=== Test Summary ==='); + console.log(`Passed: ${passed}`); + console.log(`Failed: ${failed}`); + console.log(`Total: ${passed + failed}`); + + process.exit(failed > 0 ? 1 : 0); +} + +// Check if server is reachable first +try { + const response = await fetch(SERVER_URL); + // 400 is expected (needs proper MCP request) +} catch (error) { + console.error(`\nError: Cannot reach server at ${SERVER_URL}`); + console.error('Make sure the Everything server is running:'); + console.error(' PORT=6299 npx -y @modelcontextprotocol/server-everything streamableHttp\n'); + process.exit(1); +} + +runTests(); diff --git a/core/tsconfig.json b/core/tsconfig.json new file mode 100644 index 000000000..15c1c7e90 --- /dev/null +++ b/core/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022"], + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/package.json b/package.json new file mode 100644 index 000000000..abd20337c --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "@modelcontextprotocol/inspector", + "version": "2.0.0-alpha.0", + "description": "MCP Inspector V2 - A tool for testing and debugging MCP servers", + "private": true, + "type": "module", + "workspaces": [ + "core" + ], + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "^5.7.0" + }, + "engines": { + "node": ">=18.0.0" + } +}